diff options
54 files changed, 10996 insertions, 407 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 diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx new file mode 100644 index 00000000..483ef773 --- /dev/null +++ b/components/file-manager/FileManager.tsx @@ -0,0 +1,1447 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { + Folder, + File, + FolderPlus, + Upload, + Trash2, + Edit2, + Download, + Share2, + Eye, + EyeOff, + Lock, + Unlock, + Globe, + Shield, + AlertCircle, + MoreVertical, + ChevronRight, + ChevronDown, + Search, + Grid, + List, + Copy, + X +} from 'lucide-react'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from '@/components/ui/context-menu'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb'; +import { useToast } from '@/hooks/use-toast'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; +import { useSession } from 'next-auth/react'; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { decryptWithServerAction } from '@/components/drm/drmUtils'; +import { Progress } from '@/components/ui/progress'; + +interface FileItem { + id: string; + name: string; + type: 'file' | 'folder'; + size?: number; + mimeType?: string; + category: 'public' | 'restricted' | 'confidential' | 'internal'; + externalAccessLevel?: 'view_only' | 'view_download' | 'full_access'; + updatedAt: Date; + permissions?: { + canView: boolean; + canDownload: boolean; + canEdit: boolean; + canDelete: boolean; + }; + downloadCount?: number; + viewCount?: number; + parentId?: string | null; + children?: FileItem[]; +} + +interface UploadingFile { + file: File; + progress: number; + status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error'; + error?: string; +} + +interface FileManagerProps { + projectId: string; +} + +// 카테고리별 아이콘과 색상 +const categoryConfig = { + public: { icon: Globe, color: 'text-green-500', label: '공개' }, + restricted: { icon: Eye, color: 'text-yellow-500', label: '제한' }, + confidential: { icon: Lock, color: 'text-red-500', label: '기밀' }, + internal: { icon: Shield, color: 'text-blue-500', label: '내부' }, +}; + +// Tree Item Component +const TreeItem: React.FC<{ + item: FileItem; + level: number; + expandedFolders: Set<string>; + selectedItems: Set<string>; + onToggleExpand: (id: string) => void; + onSelectItem: (id: string) => void; + onDoubleClick: (item: FileItem) => void; + onDownload: (item: FileItem) => void; + onDownloadFolder: (item: FileItem) => void; + onDelete: (ids: string[]) => void; + onShare: (item: FileItem) => void; + onRename: (item: FileItem) => void; + isInternalUser: boolean; +}> = ({ + item, + level, + expandedFolders, + selectedItems, + onToggleExpand, + onSelectItem, + onDoubleClick, + onDownload, + onDownloadFolder, + onDelete, + onShare, + onRename, + isInternalUser +}) => { + const hasChildren = item.type === 'folder' && item.children && item.children.length > 0; + const isExpanded = expandedFolders.has(item.id); + const isSelected = selectedItems.has(item.id); + const CategoryIcon = categoryConfig[item.category].icon; + const categoryColor = categoryConfig[item.category].color; + const categoryLabel = categoryConfig[item.category].label; + + const formatFileSize = (bytes?: number) => { + if (!bytes) return '-'; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; + }; + + return ( + <> + <div + className={cn( + "flex items-center p-2 rounded-lg cursor-pointer transition-colors", + "hover:bg-accent", + isSelected && "bg-accent" + )} + style={{ paddingLeft: `${level * 24 + 8}px` }} + onClick={() => onSelectItem(item.id)} + onDoubleClick={() => onDoubleClick(item)} + > + <div className="flex items-center mr-2"> + {item.type === 'folder' && ( + <button + onClick={(e) => { + e.stopPropagation(); + onToggleExpand(item.id); + }} + className="p-0.5 hover:bg-gray-200 rounded" + > + {isExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </button> + )} + {item.type === 'file' && ( + <div className="w-5" /> + )} + </div> + + {item.type === 'folder' ? ( + <Folder className="h-5 w-5 text-blue-500 mr-2" /> + ) : ( + <File className="h-5 w-5 text-gray-500 mr-2" /> + )} + + <span className="flex-1">{item.name}</span> + + <Badge variant="outline" className="mr-2"> + <CategoryIcon className={cn("h-3 w-3 mr-1", categoryColor)} /> + {categoryLabel} + </Badge> + + <span className="text-sm text-muted-foreground mr-4"> + {formatFileSize(item.size)} + </span> + <span className="text-sm text-muted-foreground mr-2"> + {new Date(item.updatedAt).toLocaleDateString()} + </span> + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="sm"> + <MoreVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent> + {item.type === 'file' && item.permissions?.canDownload && ( + <DropdownMenuItem onClick={() => onDownload(item)}> + <Download className="h-4 w-4 mr-2" /> + 다운로드 + </DropdownMenuItem> + )} + + {item.type === 'folder' && ( + <DropdownMenuItem onClick={() => onDownloadFolder(item)}> + <Download className="h-4 w-4 mr-2" /> + 폴더 전체 다운로드 + </DropdownMenuItem> + )} + + {isInternalUser && ( + <> + <DropdownMenuItem onClick={() => onShare(item)}> + <Share2 className="h-4 w-4 mr-2" /> + 공유 + </DropdownMenuItem> + + {item.permissions?.canEdit && ( + <DropdownMenuItem onClick={() => onRename(item)}> + <Edit2 className="h-4 w-4 mr-2" /> + 이름 변경 + </DropdownMenuItem> + )} + </> + )} + + {item.permissions?.canDelete && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + className="text-destructive" + onClick={() => onDelete([item.id])} + > + <Trash2 className="h-4 w-4 mr-2" /> + 삭제 + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> + </div> + + {item.type === 'folder' && isExpanded && item.children && ( + <div> + {item.children.map((child) => ( + <TreeItem + key={child.id} + item={child} + level={level + 1} + expandedFolders={expandedFolders} + selectedItems={selectedItems} + onToggleExpand={onToggleExpand} + onSelectItem={onSelectItem} + onDoubleClick={onDoubleClick} + onDownload={onDownload} + onDownloadFolder={onDownloadFolder} + onDelete={onDelete} + onShare={onShare} + onRename={onRename} + isInternalUser={isInternalUser} + /> + ))} + </div> + )} + </> + ); +}; + +export function FileManager({ projectId }: FileManagerProps) { + const { data: session } = useSession(); + const [items, setItems] = useState<FileItem[]>([]); + const [treeItems, setTreeItems] = useState<FileItem[]>([]); + const [currentPath, setCurrentPath] = useState<string[]>([]); + const [currentParentId, setCurrentParentId] = useState<string | null>(null); + const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set()); + const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); + const [searchQuery, setSearchQuery] = useState(''); + const [loading, setLoading] = useState(false); + + // 업로드 상태 + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]); + const [uploadCategory, setUploadCategory] = useState<string>('confidential'); + + // 다이얼로그 상태 + const [folderDialogOpen, setFolderDialogOpen] = useState(false); + const [shareDialogOpen, setShareDialogOpen] = useState(false); + const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + + // 다이얼로그 데이터 + const [dialogValue, setDialogValue] = useState(''); + const [selectedCategory, setSelectedCategory] = useState<string>('confidential'); + const [selectedFile, setSelectedFile] = useState<FileItem | null>(null); + const [shareSettings, setShareSettings] = useState({ + accessLevel: 'view_only', + password: '', + expiresAt: '', + maxDownloads: '', + }); + + const { toast } = useToast(); + + // 사용자가 내부 사용자인지 확인 + const isInternalUser = session?.user?.domain !== 'partners'; + + // 트리 구조 생성 함수 + const buildTree = (flatItems: FileItem[]): FileItem[] => { + const itemMap = new Map<string, FileItem>(); + const rootItems: FileItem[] = []; + + // 모든 아이템을 맵에 저장 (children 초기화) + flatItems.forEach(item => { + itemMap.set(item.id, { ...item, children: [] }); + }); + + // 부모-자식 관계 설정 + flatItems.forEach(item => { + const mappedItem = itemMap.get(item.id)!; + + if (!item.parentId) { + // parentId가 없으면 루트 아이템 + rootItems.push(mappedItem); + } else { + // parentId가 있으면 부모의 children에 추가 + const parent = itemMap.get(item.parentId); + if (parent) { + if (!parent.children) parent.children = []; + parent.children.push(mappedItem); + } else { + // 부모를 찾을 수 없으면 루트로 처리 + rootItems.push(mappedItem); + } + } + }); + + return rootItems; + }; + + // 파일 목록 가져오기 + const fetchItems = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + + // 트리 뷰일 때는 전체 목록을 가져옴 + if (viewMode === 'list') { + params.append('viewMode', 'tree'); + // 트리 뷰에서도 현재 경로 정보는 유지 (하이라이팅 등에 사용) + if (currentParentId) params.append('currentParentId', currentParentId); + } else { + // 그리드 뷰일 때는 현재 폴더의 내용만 가져옴 + if (currentParentId) params.append('parentId', currentParentId); + } + + const response = await fetch(`/api/data-room/${projectId}?${params}`); + if (!response.ok) throw new Error('Failed to fetch files'); + + const data = await response.json(); + setItems(data); + + // 트리 구조 생성 + if (viewMode === 'list') { + const tree = buildTree(data); + setTreeItems(tree); + } + } catch (error) { + toast({ + title: '오류', + description: '파일을 불러오는데 실패했습니다.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }, [projectId, currentParentId, viewMode, toast]); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + // 폴더 생성 + const createFolder = async () => { + try { + const response = await fetch(`/api/data-room/${projectId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: dialogValue, + type: 'folder', + category: selectedCategory, + parentId: currentParentId, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to create folder'); + } + + await fetchItems(); + setFolderDialogOpen(false); + setDialogValue(''); + + toast({ + title: '성공', + description: '폴더가 생성되었습니다.', + }); + } catch (error: any) { + toast({ + title: '오류', + description: error.message || '폴더 생성에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + // 파일 업로드 처리 + const handleFileUpload = async (files: FileList | File[]) => { + const fileArray = Array.from(files); + + // 업로드 파일 목록 초기화 + const newUploadingFiles: UploadingFile[] = fileArray.map(file => ({ + file, + progress: 0, + status: 'pending' as const + })); + + setUploadingFiles(newUploadingFiles); + + // 각 파일 업로드 처리 + for (let i = 0; i < fileArray.length; i++) { + const file = fileArray[i]; + + try { + // 상태 업데이트: 업로드 중 + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { ...f, status: 'uploading', progress: 20 } : f + )); + + // DRM 복호화 + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { ...f, status: 'processing', progress: 40 } : f + )); + + const decryptedData = await decryptWithServerAction(file); + + // FormData 생성 + const formData = new FormData(); + const blob = new Blob([decryptedData], { type: file.type }); + formData.append('file', blob, file.name); + formData.append('category', uploadCategory); + formData.append('fileSize', file.size.toString()); // 파일 크기 전달 + if (currentParentId) { + formData.append('parentId', currentParentId); + } + + // 업로드 진행률 업데이트 + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { ...f, progress: 60 } : f + )); + + // API 호출 + const response = await fetch(`/api/data-room/${projectId}/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Upload failed'); + } + + // 성공 + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { ...f, status: 'completed', progress: 100 } : f + )); + + } catch (error: any) { + // 실패 + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { + ...f, + status: 'error', + error: error.message || '업로드 실패' + } : f + )); + } + } + + // 모든 업로드 완료 후 목록 새로고침 + await fetchItems(); + + // 성공한 파일이 있으면 토스트 표시 + const successCount = newUploadingFiles.filter(f => f.status === 'completed').length; + if (successCount > 0) { + toast({ + title: '업로드 완료', + description: `${successCount}개 파일이 업로드되었습니다.`, + }); + } + }; + + // 폴더 다운로드 + const downloadFolder = async (folder: FileItem) => { + if (folder.type !== 'folder') return; + + try { + toast({ + title: '권한 확인 중', + description: '폴더 내 파일들의 다운로드 권한을 확인하고 있습니다...', + }); + + // 폴더 다운로드 API 호출 + const response = await fetch(`/api/data-room/${projectId}/download-folder/${folder.id}`, { + method: 'GET', + }); + + if (!response.ok) { + const error = await response.json(); + + // 권한이 없는 파일이 있는 경우 상세 정보 제공 + if (error.unauthorizedFiles) { + toast({ + title: '다운로드 권한 부족', + description: `${error.unauthorizedFiles.length}개 파일에 대한 권한이 없습니다: ${error.unauthorizedFiles.join(', ')}`, + variant: 'destructive', + }); + return; + } + + throw new Error(error.error || '폴더 다운로드 실패'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // 폴더명을 파일명에 포함 + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const fileName = `${folder.name}_${timestamp}.zip`; + + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast({ + title: '다운로드 완료', + description: `${folder.name} 폴더가 다운로드되었습니다.`, + }); + + } catch (error: any) { + toast({ + title: '오류', + description: error.message || '폴더 다운로드에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + // 파일 공유 + const shareFile = async () => { + if (!selectedFile) return; + + try { + const response = await fetch(`/api/data-room/${projectId}/share`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fileId: selectedFile.id, + ...shareSettings, + }), + }); + + if (!response.ok) { + throw new Error('Failed to create share link'); + } + + const data = await response.json(); + + // 공유 링크 복사 + await navigator.clipboard.writeText(data.shareUrl); + + toast({ + title: '공유 링크 생성됨', + description: '링크가 클립보드에 복사되었습니다.', + }); + + setShareDialogOpen(false); + setSelectedFile(null); + } catch (error) { + toast({ + title: '오류', + description: '공유 링크 생성에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + // 다중 파일 다운로드 + const downloadMultipleFiles = async (itemIds: string[]) => { + // 선택된 파일들 중 실제 파일만 필터링 (폴더 제외) + const filesToDownload = items.filter(item => + itemIds.includes(item.id) && + item.type === 'file' && + item.permissions?.canDownload + ); + + if (filesToDownload.length === 0) { + toast({ + title: '알림', + description: '다운로드 가능한 파일이 없습니다.', + variant: 'default', + }); + return; + } + + // 단일 파일인 경우 일반 다운로드 사용 + if (filesToDownload.length === 1) { + await downloadFile(filesToDownload[0]); + return; + } + + try { + toast({ + title: '다운로드 준비 중', + description: `${filesToDownload.length}개 파일을 압축하고 있습니다...`, + }); + + // 여러 파일 다운로드 API 호출 + const response = await fetch(`/api/data-room/${projectId}/download-multiple`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileIds: filesToDownload.map(f => f.id) }) + }); + + if (!response.ok) { + throw new Error('다운로드 실패'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // 현재 날짜시간을 파일명에 포함 + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const fileName = `files_${timestamp}.zip`; + + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast({ + title: '다운로드 완료', + description: `${filesToDownload.length}개 파일이 다운로드되었습니다.`, + }); + + } catch (error) { + console.error('다중 다운로드 오류:', error); + + // 실패 시 개별 다운로드 옵션 제공 + toast({ + title: '압축 다운로드 실패', + description: '개별 다운로드를 시도하시겠습니까?', + action: ( + <Button + size="sm" + variant="outline" + onClick={() => { + // 개별 다운로드 실행 + filesToDownload.forEach(async (file, index) => { + // 다운로드 간격을 두어 브라우저 부하 감소 + setTimeout(() => downloadFile(file), index * 500); + }); + }} + > + 개별 다운로드 + </Button> + ), + }); + } + }; + + // 파일 다운로드 + const downloadFile = async (file: FileItem) => { + try { + const response = await fetch(`/api/data-room/${projectId}/${file.id}/download`); + + if (!response.ok) { + throw new Error('Download failed'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = file.name; + document.body.appendChild(a); + a.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + toast({ + title: '오류', + description: '다운로드에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + // 파일 삭제 + const deleteItems = async (itemIds: string[]) => { + try { + await Promise.all( + itemIds.map(id => + fetch(`/api/data-room/${projectId}/${id}`, { method: 'DELETE' }) + ) + ); + + await fetchItems(); + setSelectedItems(new Set()); + + toast({ + title: '성공', + description: '선택한 항목이 삭제되었습니다.', + }); + } catch (error) { + toast({ + title: '오류', + description: '삭제에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + // 폴더 더블클릭 처리 + const handleFolderOpen = (folder: FileItem) => { + if (viewMode === 'grid') { + setCurrentPath([...currentPath, folder.name]); + setCurrentParentId(folder.id); + } else { + // 트리 뷰에서는 expand/collapse + const newExpanded = new Set(expandedFolders); + if (newExpanded.has(folder.id)) { + newExpanded.delete(folder.id); + } else { + newExpanded.add(folder.id); + } + setExpandedFolders(newExpanded); + } + setSelectedItems(new Set()); + }; + + // 폴더 확장 토글 + const toggleFolderExpand = (folderId: string) => { + const newExpanded = new Set(expandedFolders); + if (newExpanded.has(folderId)) { + newExpanded.delete(folderId); + } else { + newExpanded.add(folderId); + } + setExpandedFolders(newExpanded); + }; + + // 아이템 선택 + const toggleItemSelection = (itemId: string) => { + const newSelected = new Set(selectedItems); + if (newSelected.has(itemId)) { + newSelected.delete(itemId); + } else { + newSelected.add(itemId); + } + setSelectedItems(newSelected); + }; + + // 경로 탐색 + const navigateToPath = (index: number) => { + if (index === -1) { + setCurrentPath([]); + setCurrentParentId(null); + } else { + setCurrentPath(currentPath.slice(0, index + 1)); + // parentId 업데이트 로직 필요 + } + }; + + // 필터링된 아이템 + const filteredItems = items.filter(item => + item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const filteredTreeItems = treeItems.filter(item => + item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // 파일 크기 포맷 + const formatFileSize = (bytes?: number) => { + if (!bytes) return '-'; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; + }; + + return ( + <div className="flex flex-col h-full"> + {/* 툴바 */} + <div className="border-b p-4"> + <div className="flex items-center justify-between mb-3"> + <div className="flex items-center gap-2"> + {isInternalUser && ( + <> + <Button + size="sm" + onClick={() => setFolderDialogOpen(true)} + > + <FolderPlus className="h-4 w-4 mr-1" /> + 새 폴더 + </Button> + <Button + size="sm" + variant="outline" + onClick={() => setUploadDialogOpen(true)} + > + <Upload className="h-4 w-4 mr-1" /> + 업로드 + </Button> + </> + )} + + {selectedItems.size > 0 && ( + <> + {/* 다중 다운로드 버튼 */} + {items.filter(item => + selectedItems.has(item.id) && + item.type === 'file' && + item.permissions?.canDownload + ).length > 0 && ( + <Button + size="sm" + variant="outline" + onClick={() => downloadMultipleFiles(Array.from(selectedItems))} + > + <Download className="h-4 w-4 mr-1" /> + 다운로드 ({items.filter(item => + selectedItems.has(item.id) && item.type === 'file' + ).length}) + </Button> + )} + + {/* 삭제 버튼 */} + {items.find(item => selectedItems.has(item.id))?.permissions?.canDelete && ( + <Button + size="sm" + variant="destructive" + onClick={() => deleteItems(Array.from(selectedItems))} + > + <Trash2 className="h-4 w-4 mr-1" /> + 삭제 ({selectedItems.size}) + </Button> + )} + </> + )} + + {!isInternalUser && ( + <Badge variant="secondary" className="ml-2"> + <Shield className="h-3 w-3 mr-1" /> + 외부 사용자 + </Badge> + )} + </div> + + <div className="flex items-center gap-2"> + <div className="relative"> + <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="검색..." + className="pl-8 w-64" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + </div> + + <Button + size="sm" + variant="ghost" + onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')} + > + {viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />} + </Button> + </div> + </div> + + {/* Breadcrumb */} + <Breadcrumb> + <BreadcrumbList> + <BreadcrumbItem> + <BreadcrumbLink onClick={() => navigateToPath(-1)}> + Home + </BreadcrumbLink> + </BreadcrumbItem> + {currentPath.map((path, index) => ( + <BreadcrumbItem key={index}> + <ChevronRight className="h-4 w-4" /> + <BreadcrumbLink onClick={() => navigateToPath(index)}> + {path} + </BreadcrumbLink> + </BreadcrumbItem> + ))} + </BreadcrumbList> + </Breadcrumb> + </div> + + {/* 파일 목록 */} + <ScrollArea className="flex-1 p-4"> + {loading ? ( + <div className="flex justify-center items-center h-64"> + <div className="text-muted-foreground">로딩 중...</div> + </div> + ) : filteredItems.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-64"> + <Folder className="h-12 w-12 text-muted-foreground mb-2" /> + <p className="text-muted-foreground">비어있음</p> + </div> + ) : viewMode === 'grid' ? ( + <div className="grid grid-cols-6 gap-4"> + {filteredItems.map((item) => { + const CategoryIcon = categoryConfig[item.category].icon; + const categoryColor = categoryConfig[item.category].color; + + return ( + <ContextMenu key={item.id}> + <ContextMenuTrigger> + <div + className={cn( + "flex flex-col items-center p-3 rounded-lg cursor-pointer transition-colors", + "hover:bg-accent", + selectedItems.has(item.id) && "bg-accent" + )} + onClick={() => toggleItemSelection(item.id)} + onDoubleClick={() => { + if (item.type === 'folder') { + handleFolderOpen(item); + } + }} + > + <div className="relative"> + {item.type === 'folder' ? ( + <Folder className="h-12 w-12 text-blue-500" /> + ) : ( + <File className="h-12 w-12 text-gray-500" /> + )} + <CategoryIcon className={cn("h-4 w-4 absolute -bottom-1 -right-1", categoryColor)} /> + </div> + + <span className="mt-2 text-sm text-center truncate w-full"> + {item.name} + </span> + + {item.viewCount !== undefined && ( + <div className="flex items-center gap-2 mt-1"> + <span className="text-xs text-muted-foreground flex items-center"> + <Eye className="h-3 w-3 mr-1" /> + {item.viewCount} + </span> + {item.downloadCount !== undefined && ( + <span className="text-xs text-muted-foreground flex items-center"> + <Download className="h-3 w-3 mr-1" /> + {item.downloadCount} + </span> + )} + </div> + )} + </div> + </ContextMenuTrigger> + + <ContextMenuContent> + {item.type === 'folder' && ( + <> + <ContextMenuItem onClick={() => handleFolderOpen(item)}> + 열기 + </ContextMenuItem> + <ContextMenuItem onClick={() => downloadFolder(item)}> + <Download className="h-4 w-4 mr-2" /> + 폴더 전체 다운로드 + </ContextMenuItem> + </> + )} + + {item.type === 'file' && item.permissions?.canDownload && ( + <ContextMenuItem onClick={() => downloadFile(item)}> + <Download className="h-4 w-4 mr-2" /> + 다운로드 + </ContextMenuItem> + )} + + {isInternalUser && ( + <> + <ContextMenuSeparator /> + <ContextMenuSub> + <ContextMenuSubTrigger> + <Shield className="h-4 w-4 mr-2" /> + 카테고리 변경 + </ContextMenuSubTrigger> + <ContextMenuSubContent> + {Object.entries(categoryConfig).map(([key, config]) => ( + <ContextMenuItem key={key}> + <config.icon className={cn("h-4 w-4 mr-2", config.color)} /> + {config.label} + </ContextMenuItem> + ))} + </ContextMenuSubContent> + </ContextMenuSub> + + <ContextMenuItem + onClick={() => { + setSelectedFile(item); + setShareDialogOpen(true); + }} + > + <Share2 className="h-4 w-4 mr-2" /> + 공유 + </ContextMenuItem> + + {item.permissions?.canEdit && ( + <ContextMenuItem onClick={() => { + setSelectedFile(item); + setDialogValue(item.name); + setRenameDialogOpen(true); + }}> + <Edit2 className="h-4 w-4 mr-2" /> + 이름 변경 + </ContextMenuItem> + )} + </> + )} + + {item.permissions?.canDelete && ( + <> + <ContextMenuSeparator /> + <ContextMenuItem + className="text-destructive" + onClick={() => deleteItems([item.id])} + > + <Trash2 className="h-4 w-4 mr-2" /> + 삭제 + </ContextMenuItem> + </> + )} + </ContextMenuContent> + </ContextMenu> + ); + })} + </div> + ) : ( + // Tree View + <div className="space-y-1"> + {filteredTreeItems.map((item) => ( + <TreeItem + key={item.id} + item={item} + level={0} + expandedFolders={expandedFolders} + selectedItems={selectedItems} + onToggleExpand={toggleFolderExpand} + onSelectItem={toggleItemSelection} + onDoubleClick={handleFolderOpen} + onDownload={downloadFile} + onDownloadFolder={downloadFolder} + onDelete={deleteItems} + onShare={(item) => { + setSelectedFile(item); + setShareDialogOpen(true); + }} + onRename={(item) => { + setSelectedFile(item); + setDialogValue(item.name); + setRenameDialogOpen(true); + }} + isInternalUser={isInternalUser} + /> + ))} + </div> + )} + </ScrollArea> + + {/* 업로드 다이얼로그 */} + <Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>파일 업로드</DialogTitle> + <DialogDescription> + 파일을 드래그 앤 드롭하거나 클릭하여 선택하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 카테고리 선택 */} + <div> + <Label htmlFor="upload-category">카테고리</Label> + <Select value={uploadCategory} onValueChange={setUploadCategory}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {Object.entries(categoryConfig).map(([key, config]) => ( + <SelectItem key={key} value={key}> + <div className="flex items-center"> + <config.icon className={cn("h-4 w-4 mr-2", config.color)} /> + <span>{config.label}</span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* Dropzone */} + <Dropzone + onDrop={(acceptedFiles: File[]) => { + handleFileUpload(acceptedFiles); + }} + accept={{ + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-powerpoint': ['.ppt'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], + 'text/plain': ['.txt'], + 'text/csv': ['.csv'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], + 'application/zip': ['.zip'], + 'application/x-rar-compressed': ['.rar'], + 'application/x-7z-compressed': ['.7z'], + 'application/x-dwg': ['.dwg'], + 'application/x-dxf': ['.dxf'], + }} + multiple={true} + disabled={false} + > + <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg"> + <DropzoneInput /> + <div className="flex flex-col items-center justify-center h-full"> + <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" /> + <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> + <DropzoneDescription>여러 파일을 동시에 업로드할 수 있습니다</DropzoneDescription> + </div> + </DropzoneZone> + </Dropzone> + + {/* 업로드 중인 파일 목록 */} + {uploadingFiles.length > 0 && ( + <FileList> + <FileListHeader>업로드 중인 파일</FileListHeader> + {uploadingFiles.map((uploadFile, index) => ( + <FileListItem key={index}> + <FileListIcon> + <File className="h-4 w-4" /> + </FileListIcon> + <FileListInfo> + <FileListName>{uploadFile.file.name}</FileListName> + <FileListDescription> + <div className="flex items-center gap-2"> + <FileListSize>{uploadFile.file.size}</FileListSize> + {uploadFile.status === 'uploading' && <span>업로드 중...</span>} + {uploadFile.status === 'processing' && <span>처리 중...</span>} + {uploadFile.status === 'completed' && ( + <span className="text-green-600">완료</span> + )} + {uploadFile.status === 'error' && ( + <span className="text-red-600">{uploadFile.error}</span> + )} + </div> + {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && ( + <Progress value={uploadFile.progress} className="h-1 mt-1" /> + )} + </FileListDescription> + </FileListInfo> + <FileListAction> + {uploadFile.status === 'error' && ( + <Button + size="sm" + variant="ghost" + onClick={() => { + setUploadingFiles(prev => + prev.filter((_, i) => i !== index) + ); + }} + > + <X className="h-4 w-4" /> + </Button> + )} + </FileListAction> + </FileListItem> + ))} + </FileList> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setUploadDialogOpen(false); + setUploadingFiles([]); + }} + > + 닫기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 폴더 생성 다이얼로그 */} + <Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>새 폴더 만들기</DialogTitle> + <DialogDescription> + 폴더 이름과 접근 권한 카테고리를 설정하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div> + <Label htmlFor="folder-name">폴더 이름</Label> + <Input + id="folder-name" + value={dialogValue} + onChange={(e) => setDialogValue(e.target.value)} + placeholder="폴더 이름 입력" + /> + </div> + + <div> + <Label htmlFor="folder-category">카테고리</Label> + <Select value={selectedCategory} onValueChange={setSelectedCategory}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {Object.entries(categoryConfig).map(([key, config]) => ( + <SelectItem key={key} value={key}> + <div className="flex items-center"> + <config.icon className={cn("h-4 w-4 mr-2", config.color)} /> + <span>{config.label}</span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => setFolderDialogOpen(false)}> + 취소 + </Button> + <Button onClick={createFolder}>생성</Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 파일 공유 다이얼로그 */} + <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>파일 공유</DialogTitle> + <DialogDescription> + {selectedFile?.name}을(를) 공유합니다. + </DialogDescription> + </DialogHeader> + + <Tabs defaultValue="link" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="link">링크 공유</TabsTrigger> + <TabsTrigger value="permission">권한 설정</TabsTrigger> + </TabsList> + + <TabsContent value="link" className="space-y-4"> + <div> + <Label htmlFor="access-level">접근 레벨</Label> + <Select + value={shareSettings.accessLevel} + onValueChange={(value) => setShareSettings({...shareSettings, accessLevel: value})} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="view_only"> + <div className="flex items-center"> + <Eye className="h-4 w-4 mr-2" /> + 보기만 가능 + </div> + </SelectItem> + <SelectItem value="view_download"> + <div className="flex items-center"> + <Download className="h-4 w-4 mr-2" /> + 보기 + 다운로드 + </div> + </SelectItem> + </SelectContent> + </Select> + </div> + + <div> + <Label htmlFor="password">비밀번호 (선택)</Label> + <Input + id="password" + type="password" + value={shareSettings.password} + onChange={(e) => setShareSettings({...shareSettings, password: e.target.value})} + placeholder="비밀번호 입력" + /> + </div> + + <div> + <Label htmlFor="expires">만료일 (선택)</Label> + <Input + id="expires" + type="datetime-local" + value={shareSettings.expiresAt} + onChange={(e) => setShareSettings({...shareSettings, expiresAt: e.target.value})} + /> + </div> + + <div> + <Label htmlFor="max-downloads">최대 다운로드 횟수 (선택)</Label> + <Input + id="max-downloads" + type="number" + value={shareSettings.maxDownloads} + onChange={(e) => setShareSettings({...shareSettings, maxDownloads: e.target.value})} + placeholder="무제한" + /> + </div> + </TabsContent> + + <TabsContent value="permission" className="space-y-4"> + <div> + <Label htmlFor="target-domain">대상 도메인</Label> + <Select> + <SelectTrigger> + <SelectValue placeholder="도메인 선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="partners">파트너</SelectItem> + <SelectItem value="internal">내부</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <Label>권한</Label> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label htmlFor="can-view" className="text-sm font-normal">보기</Label> + <Switch id="can-view" defaultChecked /> + </div> + <div className="flex items-center justify-between"> + <Label htmlFor="can-download" className="text-sm font-normal">다운로드</Label> + <Switch id="can-download" /> + </div> + <div className="flex items-center justify-between"> + <Label htmlFor="can-edit" className="text-sm font-normal">수정</Label> + <Switch id="can-edit" /> + </div> + <div className="flex items-center justify-between"> + <Label htmlFor="can-share" className="text-sm font-normal">공유</Label> + <Switch id="can-share" /> + </div> + </div> + </div> + </TabsContent> + </Tabs> + + <DialogFooter> + <Button variant="outline" onClick={() => setShareDialogOpen(false)}> + 취소 + </Button> + <Button onClick={shareFile}> + <Share2 className="h-4 w-4 mr-2" /> + 공유 링크 생성 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ); +}
\ No newline at end of file diff --git a/components/file-manager/SharedFileViewer.tsx b/components/file-manager/SharedFileViewer.tsx new file mode 100644 index 00000000..a6e4eef5 --- /dev/null +++ b/components/file-manager/SharedFileViewer.tsx @@ -0,0 +1,411 @@ +// components/file-manager/SharedFileViewer.tsx +'use client'; + +import { useState, useEffect } from 'react'; +import { + Download, + Eye, + EyeOff, + FileText, + Image, + Film, + Music, + Archive, + Code, + File, + Lock, + AlertCircle, + Calendar, + Clock, + User +} 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 { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; + +interface SharedFile { + id: string; + name: string; + type: 'file' | 'folder'; + size: number; + mimeType?: string; + category: string; + createdAt: string; + updatedAt: string; +} + +interface SharedFileViewerProps { + token: string; +} + +export function SharedFileViewer({ token }: SharedFileViewerProps) { + const [file, setFile] = useState<SharedFile | null>(null); + const [accessLevel, setAccessLevel] = useState<string>(''); + const [passwordRequired, setPasswordRequired] = useState(false); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [showContent, setShowContent] = useState(false); + const [downloading, setDownloading] = useState(false); + + const { toast } = useToast(); + + useEffect(() => { + // 초기 접근 시도 + checkAccess(); + }, [token]); + + const checkAccess = async (pwd?: string) => { + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + if (pwd) params.append('password', pwd); + + const response = await fetch(`/api/shared/${token}?${params}`); + const data = await response.json(); + + if (!response.ok) { + if (data.error?.includes('비밀번호')) { + setPasswordRequired(true); + setError('비밀번호가 필요합니다'); + } else if (data.error?.includes('만료')) { + setError('이 공유 링크는 만료되었습니다'); + } else if (data.error?.includes('최대 다운로드')) { + setError('최대 다운로드 횟수를 초과했습니다'); + } else { + setError(data.error || '파일에 접근할 수 없습니다'); + } + return; + } + + setFile(data.file); + setAccessLevel(data.accessLevel); + setShowContent(true); + setPasswordRequired(false); + } catch (err) { + setError('파일을 불러오는 중 오류가 발생했습니다'); + } finally { + setLoading(false); + } + }; + + const handlePasswordSubmit = (e: React.FormEvent) => { + e.preventDefault(); + checkAccess(password); + }; + + const handleDownload = async () => { + if (!file || accessLevel !== 'view_download') return; + + setDownloading(true); + try { + const response = await fetch(`/api/shared/${token}/download`, { + method: 'POST', + headers: password ? { 'X-Share-Password': password } : {}, + }); + + if (!response.ok) { + throw new Error('다운로드 실패'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = file.name; + document.body.appendChild(a); + a.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast({ + title: '다운로드 완료', + description: `${file.name} 파일이 다운로드되었습니다.`, + }); + } catch (error) { + toast({ + title: '다운로드 실패', + description: '파일 다운로드 중 오류가 발생했습니다.', + variant: 'destructive', + }); + } finally { + setDownloading(false); + } + }; + + const getFileIcon = (mimeType?: string, name?: string) => { + if (!mimeType && name) { + const ext = name.split('.').pop()?.toLowerCase(); + if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) return Image; + if (['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) return Film; + if (['mp3', 'wav', 'flac'].includes(ext || '')) return Music; + if (['zip', 'rar', '7z', 'tar'].includes(ext || '')) return Archive; + if (['js', 'ts', 'py', 'java', 'cpp'].includes(ext || '')) return Code; + if (['pdf', 'doc', 'docx', 'txt'].includes(ext || '')) return FileText; + } + + if (mimeType?.startsWith('image/')) return Image; + if (mimeType?.startsWith('video/')) return Film; + if (mimeType?.startsWith('audio/')) return Music; + if (mimeType?.includes('zip') || mimeType?.includes('compressed')) return Archive; + if (mimeType?.includes('pdf')) return FileText; + + return File; + }; + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + if (loading) { + return ( + <div className="min-h-screen flex items-center justify-center"> + <div className="text-center"> + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4" /> + <p className="text-muted-foreground">파일 정보를 불러오는 중...</p> + </div> + </div> + ); + } + + if (error && !passwordRequired) { + return ( + <div className="min-h-screen flex items-center justify-center p-4"> + <Card className="max-w-md w-full"> + <CardHeader> + <div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4"> + <AlertCircle className="h-6 w-6 text-red-600" /> + </div> + <CardTitle className="text-center">접근할 수 없습니다</CardTitle> + </CardHeader> + <CardContent> + <Alert variant="destructive"> + <AlertDescription>{error}</AlertDescription> + </Alert> + </CardContent> + </Card> + </div> + ); + } + + if (passwordRequired && !showContent) { + return ( + <div className="min-h-screen flex items-center justify-center p-4"> + <Card className="max-w-md w-full"> + <CardHeader> + <div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4"> + <Lock className="h-6 w-6 text-blue-600" /> + </div> + <CardTitle className="text-center">비밀번호 입력</CardTitle> + <CardDescription className="text-center"> + 이 파일은 비밀번호로 보호되어 있습니다 + </CardDescription> + </CardHeader> + <CardContent> + <form onSubmit={handlePasswordSubmit} className="space-y-4"> + <div> + <Label htmlFor="password">비밀번호</Label> + <Input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder="비밀번호를 입력하세요" + autoFocus + /> + </div> + {error && ( + <Alert variant="destructive"> + <AlertDescription>{error}</AlertDescription> + </Alert> + )} + <Button type="submit" className="w-full"> + <Lock className="h-4 w-4 mr-2" /> + 확인 + </Button> + </form> + </CardContent> + </Card> + </div> + ); + } + + if (!file) return null; + + const FileIcon = getFileIcon(file.mimeType, file.name); + + return ( + <div className="min-h-screen bg-gray-50"> + {/* 헤더 */} + <div className="bg-white border-b"> + <div className="container mx-auto px-4 py-4"> + <div className="flex items-center gap-3"> + <div className="h-10 w-10 bg-blue-600 rounded-lg flex items-center justify-center"> + <span className="text-white font-bold">FM</span> + </div> + <div> + <h1 className="text-lg font-semibold">공유된 파일</h1> + <p className="text-sm text-muted-foreground">File Manager Shared</p> + </div> + </div> + </div> + </div> + + {/* 메인 컨텐츠 */} + <div className="container mx-auto px-4 py-8 max-w-4xl"> + <Card> + <CardHeader> + <div className="flex items-start justify-between"> + <div className="flex items-start gap-4"> + <div className={cn( + "h-16 w-16 rounded-lg flex items-center justify-center", + "bg-gradient-to-br from-blue-50 to-blue-100" + )}> + <FileIcon className="h-8 w-8 text-blue-600" /> + </div> + <div> + <CardTitle className="text-2xl">{file.name}</CardTitle> + <CardDescription className="mt-1"> + {file.type === 'folder' ? '폴더' : formatFileSize(file.size)} + </CardDescription> + </div> + </div> + + <div className="flex items-center gap-2"> + {accessLevel === 'view_only' && ( + <Badge variant="secondary"> + <Eye className="h-3 w-3 mr-1" /> + 보기 전용 + </Badge> + )} + {accessLevel === 'view_download' && ( + <Badge variant="default"> + <Download className="h-3 w-3 mr-1" /> + 다운로드 가능 + </Badge> + )} + </div> + </div> + </CardHeader> + + <CardContent> + <div className="space-y-6"> + {/* 파일 정보 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-3"> + <div> + <p className="text-sm text-muted-foreground mb-1">파일 유형</p> + <p className="font-medium">{file.mimeType || '알 수 없음'}</p> + </div> + <div> + <p className="text-sm text-muted-foreground mb-1">생성일</p> + <p className="font-medium flex items-center gap-1"> + <Calendar className="h-4 w-4" /> + {new Date(file.createdAt).toLocaleDateString()} + </p> + </div> + </div> + + <div className="space-y-3"> + <div> + <p className="text-sm text-muted-foreground mb-1">카테고리</p> + <Badge variant="outline">{file.category}</Badge> + </div> + <div> + <p className="text-sm text-muted-foreground mb-1">수정일</p> + <p className="font-medium flex items-center gap-1"> + <Clock className="h-4 w-4" /> + {new Date(file.updatedAt).toLocaleDateString()} + </p> + </div> + </div> + </div> + + <Separator /> + + {/* 미리보기 영역 (이미지인 경우) */} + {file.mimeType?.startsWith('image/') && accessLevel !== 'download_only' && ( + <div className="bg-gray-50 rounded-lg p-4"> + <p className="text-sm text-muted-foreground mb-3">미리보기</p> + <div className="bg-white rounded border p-4"> + <img + src={`/api/shared/${token}/preview`} + alt={file.name} + className="max-w-full h-auto rounded" + /> + </div> + </div> + )} + + {/* 액션 버튼 */} + <div className="flex gap-3"> + {accessLevel === 'view_download' && ( + <Button + onClick={handleDownload} + disabled={downloading} + className="flex-1" + > + {downloading ? ( + <> + <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" /> + 다운로드 중... + </> + ) : ( + <> + <Download className="h-4 w-4 mr-2" /> + 파일 다운로드 + </> + )} + </Button> + )} + + {accessLevel === 'view_only' && ( + <Alert className="flex-1"> + <Eye className="h-4 w-4" /> + <AlertDescription> + 이 파일은 보기 전용입니다. 다운로드할 수 없습니다. + </AlertDescription> + </Alert> + )} + </div> + + {/* 보안 안내 */} + <Alert> + <Lock className="h-4 w-4" /> + <AlertDescription> + 이 링크는 보안을 위해 제한된 시간 동안만 유효합니다. + 필요한 경우 파일을 다운로드하여 보관하세요. + </AlertDescription> + </Alert> + </div> + </CardContent> + </Card> + + {/* 하단 정보 */} + <div className="mt-6 text-center text-sm text-muted-foreground"> + <p>© 2024 File Manager. All rights reserved.</p> + <p className="mt-1"> + 문제가 있으신가요?{' '} + <a href="/support" className="text-primary hover:underline"> + 고객 지원 + </a> + </p> + </div> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/components/project/ProjectDashboard.tsx b/components/project/ProjectDashboard.tsx new file mode 100644 index 00000000..d9ec2e0c --- /dev/null +++ b/components/project/ProjectDashboard.tsx @@ -0,0 +1,476 @@ +// components/project/ProjectDashboard.tsx +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Crown, + Users, + Settings, + FolderOpen, + Shield, + UserPlus, + Trash2, + BarChart3, + Eye, + Download, + HardDrive, + UserCog, + Loader2 +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +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 { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { useSession } from 'next-auth/react'; + +interface ProjectDashboardProps { + projectId: string; +} + +interface ProjectStats { + files: { + totalFiles: number; + totalSize: number; + publicFiles: number; + restrictedFiles: number; + confidentialFiles: number; + }; + members: { + totalMembers: number; + admins: number; + editors: number; + viewers: number; + }; + activity: { + views: number; + downloads: number; + uploads: number; + uniqueUsers: number; + }; +} + +export function ProjectDashboard({ projectId }: ProjectDashboardProps) { + const { data: session } = useSession(); + const [isOwner, setIsOwner] = useState(false); + const [projectRole, setProjectRole] = useState<string>('viewer'); + const [stats, setStats] = useState<ProjectStats | null>(null); + const [members, setMembers] = useState<any[]>([]); + const [loading, setLoading] = useState(true); + + console.log(stats) + + // 다이얼로그 상태 + const [addMemberOpen, setAddMemberOpen] = useState(false); + const [transferOwnershipOpen, setTransferOwnershipOpen] = useState(false); + const [newMemberEmail, setNewMemberEmail] = useState(''); + const [newMemberRole, setNewMemberRole] = useState('viewer'); + const [newOwnerId, setNewOwnerId] = useState(''); + + const { toast } = useToast(); + + // 프로젝트 정보 및 권한 확인 + useEffect(() => { + const fetchProjectData = async () => { + try { + // 권한 확인 + const accessRes = await fetch(`/api/projects/${projectId}/access`); + const accessData = await accessRes.json(); + setIsOwner(accessData.isOwner); + setProjectRole(accessData.role); + + // Owner인 경우 통계 가져오기 + if (accessData.isOwner) { + const statsRes = await fetch(`/api/projects/${projectId}/stats`); + const statsData = await statsRes.json(); + setStats(statsData); + } + + // 멤버 목록 가져오기 + const membersRes = await fetch(`/api/projects/${projectId}/members`); + const membersData = await membersRes.json(); + setMembers(membersData.member); + + } catch (error) { + console.error('프로젝트 데이터 로드 실패:', error); + } finally { + setLoading(false); + } + }; + + fetchProjectData(); + }, [projectId]); + + // 멤버 추가 + const handleAddMember = async () => { + try { + const response = await fetch(`/api/projects/${projectId}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: newMemberEmail, + role: newMemberRole, + }), + }); + + if (!response.ok) { + throw new Error('멤버 추가 실패'); + } + + toast({ + title: '성공', + description: '새 멤버가 추가되었습니다.', + }); + + setAddMemberOpen(false); + // 멤버 목록 새로고침 + } catch (error) { + toast({ + title: '오류', + description: '멤버 추가에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + // 소유권 이전 + const handleTransferOwnership = async () => { + try { + const response = await fetch(`/api/projects/${projectId}/transfer-ownership`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + newOwnerId: newOwnerId, + }), + }); + + if (!response.ok) { + throw new Error('소유권 이전 실패'); + } + + toast({ + title: '성공', + description: '프로젝트 소유권이 이전되었습니다.', + }); + + setTransferOwnershipOpen(false); + setIsOwner(false); + } catch (error) { + toast({ + title: '오류', + description: '소유권 이전에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + 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 roleConfig = { + owner: { label: 'Owner', icon: Crown, color: 'text-yellow-500' }, + admin: { label: 'Admin', icon: Shield, color: 'text-blue-500' }, + editor: { label: 'Editor', icon: FolderOpen, color: 'text-green-500' }, + viewer: { label: 'Viewer', icon: Eye, color: 'text-gray-500' }, + }; + + 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 className="flex items-center gap-3"> + <h1 className="text-2xl font-bold">프로젝트 대시보드</h1> + <Badge variant="outline" className="flex items-center gap-1"> + {roleConfig[projectRole as keyof typeof roleConfig].icon && + React.createElement(roleConfig[projectRole as keyof typeof roleConfig].icon, { + className: `h-3 w-3 ${roleConfig[projectRole as keyof typeof roleConfig].color}` + }) + } + {roleConfig[projectRole as keyof typeof roleConfig].label} + </Badge> + </div> + + {isOwner && ( + <div className="flex gap-2"> + <Button onClick={() => setAddMemberOpen(true)}> + <UserPlus className="h-4 w-4 mr-2" /> + 멤버 추가 + </Button> + <Button variant="outline"> + <Settings className="h-4 w-4 mr-2" /> + 설정 + </Button> + </div> + )} + </div> + + {/* Owner 전용 통계 */} + {isOwner && stats && ( + <div className="grid grid-cols-4 gap-4"> + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">총 파일 수</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.storage.fileCount}</div> + <p className="text-xs text-muted-foreground mt-1"> + {formatBytes(stats.storage.used)} + </p> + </CardContent> + </Card> + + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">멤버</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.users.total}</div> + <div className="flex gap-2 mt-1"> + <span className="text-xs text-muted-foreground"> + 관리자 {stats.users.byRole.admins} + </span> + <span className="text-xs text-muted-foreground"> + 편집자 {stats.users.byRole.editors} + </span> + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">조회수 (30일)</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.activity.views}</div> + <p className="text-xs text-muted-foreground mt-1"> + 활성 사용자 {stats.users.active}명 + </p> + </CardContent> + </Card> + + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-sm font-medium">다운로드 (30일)</CardTitle> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.activity.downloads}</div> + <p className="text-xs text-muted-foreground mt-1"> + 업로드 {stats.activity.uploads}개 + </p> + </CardContent> + </Card> + </div> + )} + + {/* 탭 컨텐츠 */} + <Tabs defaultValue="members"> + <TabsList> + <TabsTrigger value="members">멤버</TabsTrigger> + {isOwner && ( + <> + <TabsTrigger value="permissions">권한 관리</TabsTrigger> + <TabsTrigger value="danger">위험 영역</TabsTrigger> + </> + )} + </TabsList> + + <TabsContent value="members" className="mt-6"> + <Card> + <CardHeader> + <CardTitle>프로젝트 멤버</CardTitle> + <CardDescription> + 이 프로젝트에 접근할 수 있는 사용자 목록 + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-3"> + {members.map((member) => ( + <div key={member.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div className="flex items-center gap-3"> + <div className="h-10 w-10 bg-gray-100 rounded-full flex items-center justify-center"> + {member.user.name?.charAt(0).toUpperCase()} + </div> + <div> + <p className="font-medium">{member.user.name}</p> + <p className="text-sm text-muted-foreground">{member.user.email}</p> + </div> + </div> + <Badge variant="secondary"> + {roleConfig[member.role as keyof typeof roleConfig].icon && + React.createElement(roleConfig[member.role as keyof typeof roleConfig].icon, { + className: `h-3 w-3 mr-1 ${roleConfig[member.role as keyof typeof roleConfig].color}` + }) + } + {roleConfig[member.role as keyof typeof roleConfig].label} + </Badge> + </div> + ))} + </div> + </CardContent> + </Card> + </TabsContent> + + {isOwner && ( + <TabsContent value="danger" className="mt-6"> + <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={() => setTransferOwnershipOpen(true)} + > + <UserCog 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"> + <Trash2 className="h-4 w-4 mr-2" /> + 프로젝트 삭제 + </Button> + </div> + </CardContent> + </Card> + </TabsContent> + )} + </Tabs> + + {/* 멤버 추가 다이얼로그 */} + <Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>멤버 추가</DialogTitle> + <DialogDescription> + 프로젝트에 새 멤버를 추가합니다 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div> + <Label htmlFor="email">이메일</Label> + <Input + id="email" + type="email" + value={newMemberEmail} + onChange={(e) => setNewMemberEmail(e.target.value)} + placeholder="user@example.com" + /> + </div> + + <div> + <Label htmlFor="role">역할</Label> + <Select value={newMemberRole} onValueChange={setNewMemberRole}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem> + <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem> + <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem> + </SelectContent> + </Select> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => setAddMemberOpen(false)}> + 취소 + </Button> + <Button onClick={handleAddMember}>추가</Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 소유권 이전 다이얼로그 */} + <Dialog open={transferOwnershipOpen} onOpenChange={setTransferOwnershipOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>소유권 이전</DialogTitle> + <DialogDescription className="text-red-600"> + 주의: 이 작업은 되돌릴 수 없습니다. 프로젝트의 모든 권한이 새 소유자에게 이전됩니다. + </DialogDescription> + </DialogHeader> + + <div> + <Label htmlFor="new-owner">새 소유자 선택</Label> + <Select value={newOwnerId} onValueChange={setNewOwnerId}> + <SelectTrigger> + <SelectValue placeholder="멤버 선택" /> + </SelectTrigger> + <SelectContent> + {members + .filter(m => m.role !== 'owner') + .map(member => ( + <SelectItem key={member.userId} value={member.userId.toString()}> + {member.user.name} ({member.user.email}) + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => setTransferOwnershipOpen(false)}> + 취소 + </Button> + <Button variant="destructive" onClick={handleTransferOwnership}> + 소유권 이전 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ); +}
\ No newline at end of file diff --git a/components/project/ProjectHeader.tsx b/components/project/ProjectHeader.tsx new file mode 100644 index 00000000..34a3f43e --- /dev/null +++ b/components/project/ProjectHeader.tsx @@ -0,0 +1,84 @@ +// components/project/ProjectHeader.tsx +'use client'; + +import { useSession } from 'next-auth/react'; +import { Bell, Search, HelpCircle, User } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Badge } from '@/components/ui/badge'; + +export function ProjectHeader() { + const { data: session } = useSession(); + + return ( + <header className="border-b bg-white sticky top-0 z-50"> + <div className="container mx-auto px-4"> + <div className="flex items-center justify-between h-16"> + {/* 로고 */} + <div className="flex items-center gap-6"> + <div className="flex items-center gap-2"> + <div className="h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center"> + <span className="text-white font-bold">FM</span> + </div> + <span className="text-xl font-semibold">File Manager</span> + </div> + </div> + + {/* 우측 메뉴 */} + <div className="flex items-center gap-3"> + {/* 검색 */} + <Button variant="ghost" size="icon"> + <Search className="h-5 w-5" /> + </Button> + + {/* 알림 */} + <Button variant="ghost" size="icon" className="relative"> + <Bell className="h-5 w-5" /> + <span className="absolute top-0 right-0 h-2 w-2 bg-red-500 rounded-full" /> + </Button> + + {/* 도움말 */} + <Button variant="ghost" size="icon"> + <HelpCircle className="h-5 w-5" /> + </Button> + + {/* 사용자 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="gap-2"> + <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center"> + <User className="h-4 w-4" /> + </div> + <span className="hidden md:inline">{session?.user?.name}</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuLabel> + <div> + <p className="font-medium">{session?.user?.name}</p> + <p className="text-sm text-muted-foreground">{session?.user?.email}</p> + </div> + </DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem>프로필</DropdownMenuItem> + <DropdownMenuItem>설정</DropdownMenuItem> + <DropdownMenuItem>팀 관리</DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem className="text-red-600"> + 로그아웃 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </div> + </header> + ); +}
\ No newline at end of file diff --git a/components/project/ProjectList.tsx b/components/project/ProjectList.tsx new file mode 100644 index 00000000..4a4f7962 --- /dev/null +++ b/components/project/ProjectList.tsx @@ -0,0 +1,463 @@ +// components/project/ProjectList.tsx +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { + Plus, + Folder, + Users, + Globe, + Lock, + Crown, + Calendar, + Search, + Filter, + Grid3x3, + List +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; + +interface Project { + id: string; + code: string; + name: string; + description?: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; + role?: string; + memberCount?: number; + fileCount?: number; +} + +interface ProjectFormData { + code: string; + name: string; + description?: string; + isPublic: boolean; +} + +export function ProjectList() { + const [projects, setProjects] = useState<{ + owned: Project[]; + member: Project[]; + public: Project[]; + }>({ owned: [], member: [], public: [] }); + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const router = useRouter(); + const { toast } = useToast(); + + // React Hook Form 설정 + const { + register, + handleSubmit, + reset, + formState: { errors, isValid }, + watch, + setValue, + } = useForm<ProjectFormData>({ + mode: 'onChange', + defaultValues: { + code: '', + name: '', + description: '', + isPublic: false, + }, + }); + + const watchIsPublic = watch('isPublic'); + + useEffect(() => { + fetchProjects(); + }, []); + + const fetchProjects = async () => { + try { + const response = await fetch('/api/projects'); + const data = await response.json(); + setProjects(data); + } catch (error) { + toast({ + title: '오류', + description: '프로젝트 목록을 불러올 수 없습니다.', + variant: 'destructive', + }); + } + }; + + const onSubmit = async (data: ProjectFormData) => { + setIsSubmitting(true); + try { + const response = await fetch('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + + if (!response.ok) throw new Error('프로젝트 생성 실패'); + + const project = await response.json(); + + toast({ + title: '성공', + description: '프로젝트가 생성되었습니다.', + }); + + setCreateDialogOpen(false); + reset(); + fetchProjects(); + + // 생성된 프로젝트로 이동 + router.push(`/evcp/data-room/${project.id}`); + } catch (error) { + toast({ + title: '오류', + description: '프로젝트 생성에 실패했습니다.', + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleDialogClose = (open: boolean) => { + setCreateDialogOpen(open); + if (!open) { + reset(); + } + }; + + const filteredProjects = { + owned: projects.owned?.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) + ), + member: projects.member?.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) + ), + public: projects.public?.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) + ), + }; + + const ProjectCard = ({ project, role }: { project: Project; role?: string }) => ( + <Card + className="cursor-pointer hover:shadow-lg transition-shadow" + onClick={() => router.push(`/evcp/data-room/${project.id}/files`)} + > + <CardHeader> + <div className="flex items-start justify-between"> + <div className="flex items-center gap-2"> + <Folder className="h-5 w-5 text-blue-500" /> + <CardTitle className="text-base">{project.code} {project.name}</CardTitle> + </div> + {role === 'owner' && ( + <Crown className="h-4 w-4 text-yellow-500" /> + )} + {project.isPublic ? ( + <Globe className="h-4 w-4 text-green-500" /> + ) : ( + <Lock className="h-4 w-4 text-gray-500" /> + )} + </div> + <CardDescription className="line-clamp-2"> + {project.description || '설명이 없습니다'} + </CardDescription> + </CardHeader> + <CardContent> + <div className="flex items-center justify-between text-sm text-muted-foreground"> + <div className="flex items-center gap-3"> + {project.memberCount && ( + <span className="flex items-center gap-1"> + <Users className="h-3 w-3" /> + {project.memberCount} + </span> + )} + {project.fileCount !== undefined && ( + <span className="flex items-center gap-1"> + <Folder className="h-3 w-3" /> + {project.fileCount} + </span> + )} + </div> + <span className="flex items-center gap-1"> + <Calendar className="h-3 w-3" /> + {new Date(project.updatedAt).toLocaleDateString()} + </span> + </div> + {role && ( + <Badge variant="secondary" className="mt-2"> + {role} + </Badge> + )} + </CardContent> + </Card> + ); + + return ( + <> + {/* 헤더 */} + <div className="flex items-center justify-between mb-6"> + <div> + <h1 className="text-3xl font-bold">프로젝트</h1> + <p className="text-muted-foreground mt-1"> + 파일을 관리하고 팀과 협업하세요 + </p> + </div> + {/* <Button onClick={() => setCreateDialogOpen(true)}> + <Plus className="h-4 w-4 mr-2" /> + 새 프로젝트 + </Button> */} + </div> + + {/* 검색 및 필터 */} + <div className="flex items-center gap-3 mb-6"> + <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> + <Button + variant="outline" + size="icon" + onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')} + > + {viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid3x3 className="h-4 w-4" />} + </Button> + </div> + + {/* 프로젝트 목록 */} + <Tabs defaultValue="owned" className="space-y-6"> + <TabsList> + <TabsTrigger value="member"> + 참여 프로젝트 ({filteredProjects.member?.length}) + </TabsTrigger> + <TabsTrigger value="public"> + 공개 프로젝트 ({filteredProjects.public?.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="member"> + {filteredProjects.member?.length === 0 ? ( + <div className="text-center py-12"> + <Users className="h-12 w-12 text-muted-foreground mx-auto mb-3" /> + <p className="text-muted-foreground">참여 중인 프로젝트가 없습니다</p> + </div> + ) : viewMode === 'grid' ? ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> + {filteredProjects.member?.map(project => ( + <ProjectCard key={project.id} project={project} role={project.role} /> + ))} + </div> + ) : ( + <div className="space-y-2"> + {filteredProjects.member?.map(project => ( + <Card + key={project.id} + className="cursor-pointer hover:shadow transition-shadow" + onClick={() => router.push(`/evcp/data-room/${project.id}/files`)} + > + <CardContent className="flex items-center justify-between p-4"> + <div className="flex items-center gap-3"> + <Folder className="h-5 w-5 text-blue-500" /> + <div> + <p className="font-medium">{project.name}</p> + <p className="text-sm text-muted-foreground"> + {project.description || '설명이 없습니다'} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + <Badge variant="secondary">{project.role}</Badge> + {project.isPublic ? ( + <Globe className="h-4 w-4 text-green-500" /> + ) : ( + <Lock className="h-4 w-4 text-gray-500" /> + )} + </div> + </CardContent> + </Card> + ))} + </div> + )} + </TabsContent> + + <TabsContent value="public"> + {filteredProjects.public?.length === 0 ? ( + <div className="text-center py-12"> + <Globe className="h-12 w-12 text-muted-foreground mx-auto mb-3" /> + <p className="text-muted-foreground">공개 프로젝트가 없습니다</p> + </div> + ) : viewMode === 'grid' ? ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> + {filteredProjects.public?.map(project => ( + <ProjectCard key={project.id} project={project} /> + ))} + </div> + ) : ( + <div className="space-y-2"> + {filteredProjects.public?.map(project => ( + <Card + key={project.id} + className="cursor-pointer hover:shadow transition-shadow" + onClick={() => router.push(`/evcp/data-room/${project.id}/files`)} + > + <CardContent className="flex items-center justify-between p-4"> + <div className="flex items-center gap-3"> + <Globe className="h-5 w-5 text-green-500" /> + <div> + <p className="font-medium">{project.name}</p> + <p className="text-sm text-muted-foreground"> + {project.description || '설명이 없습니다'} + </p> + </div> + </div> + <Badge variant="outline">공개</Badge> + </CardContent> + </Card> + ))} + </div> + )} + </TabsContent> + </Tabs> + + {/* 프로젝트 생성 다이얼로그 */} + <Dialog open={createDialogOpen} onOpenChange={handleDialogClose}> + <DialogContent> + <DialogHeader> + <DialogTitle>새 프로젝트 만들기</DialogTitle> + <DialogDescription> + 팀과 파일을 공유할 새 프로젝트를 생성합니다 + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> + <div> + <Label htmlFor="code"> + 프로젝트 코드 <span className="text-red-500">*</span> + </Label> + <Input + id="code" + {...register('code', { + required: '프로젝트 코드는 필수입니다', + minLength: { + value: 2, + message: '프로젝트 코드는 최소 2자 이상이어야 합니다', + }, + pattern: { + value: /^[A-Z0-9]+$/, + message: '프로젝트 코드는 대문자와 숫자만 사용 가능합니다', + }, + })} + placeholder="SN1001" + className={errors.code ? 'border-red-500' : ''} + /> + {errors.code && ( + <p className="text-sm text-red-500 mt-1">{errors.code.message}</p> + )} + </div> + + <div> + <Label htmlFor="name"> + 프로젝트 이름 <span className="text-red-500">*</span> + </Label> + <Input + id="name" + {...register('name', { + required: '프로젝트 이름은 필수입니다', + minLength: { + value: 2, + message: '프로젝트 이름은 최소 2자 이상이어야 합니다', + }, + maxLength: { + value: 50, + message: '프로젝트 이름은 50자를 초과할 수 없습니다', + }, + })} + placeholder="예: FNLG" + className={errors.name ? 'border-red-500' : ''} + /> + {errors.name && ( + <p className="text-sm text-red-500 mt-1">{errors.name.message}</p> + )} + </div> + + <div> + <Label htmlFor="description">설명 (선택)</Label> + <Input + id="description" + {...register('description', { + maxLength: { + value: 200, + message: '설명은 200자를 초과할 수 없습니다', + }, + })} + placeholder="프로젝트에 대한 간단한 설명" + className={errors.description ? 'border-red-500' : ''} + /> + {errors.description && ( + <p className="text-sm text-red-500 mt-1">{errors.description.message}</p> + )} + </div> + + <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={watchIsPublic} + onCheckedChange={(checked) => setValue('isPublic', checked)} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => handleDialogClose(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={!isValid || isSubmitting} + > + {isSubmitting ? '생성 중...' : '프로젝트 생성'} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + </> + ); +}
\ No newline at end of file diff --git a/components/project/ProjectNav.tsx b/components/project/ProjectNav.tsx new file mode 100644 index 00000000..acf9bfd8 --- /dev/null +++ b/components/project/ProjectNav.tsx @@ -0,0 +1,149 @@ +// components/project/ProjectNav.tsx +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { + Home, + FolderOpen, + Users, + Settings, + BarChart3, + Share2, + ChevronDown, + ExternalLink +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbSeparator,BreadcrumbList +} from '@/components/ui/breadcrumb'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +interface ProjectNavProps { + projectId: string; +} + +export function ProjectNav({ projectId }: ProjectNavProps) { + const [projectName, setProjectName] = useState(''); + const [projectRole, setProjectRole] = useState(''); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + // 프로젝트 정보 가져오기 + fetchProjectInfo(); + }, [projectId]); + + const fetchProjectInfo = async () => { + try { + const response = await fetch(`/api/projects/${projectId}`); + const data = await response.json(); + setProjectName(data.name); + setProjectRole(data.role); + } catch (error) { + console.error('프로젝트 정보 로드 실패:', error); + } + }; + + const navItems = [ + { + label: '대시보드', + icon: Home, + href: `/evcp/data-room/${projectId}`, + active: pathname === `/evcp/data-room/${projectId}`, + }, + { + label: '파일', + icon: FolderOpen, + href: `/evcp/data-room/${projectId}/files`, + active: pathname === `/evcp/data-room/${projectId}/files`, + }, + { + label: '멤버', + icon: Users, + href: `/evcp/data-room/${projectId}/members`, + active: pathname === `/evcp/data-room/${projectId}/members`, + requireRole: ['owner', 'admin'], + }, + { + label: '통계', + icon: BarChart3, + href: `/evcp/data-room/${projectId}/stats`, + active: pathname === `/evcp/data-room/${projectId}/stats`, + requireRole: ['owner'], + }, + { + label: '설정', + icon: Settings, + href: `/evcp/data-room/${projectId}/settings`, + active: pathname === `/evcp/data-room/${projectId}/settings`, + requireRole: ['owner', 'admin'], + }, + ]; + + const visibleNavItems = navItems.filter(item => + !item.requireRole || item.requireRole.includes(projectRole) + ); + + return ( + <div className="border-b bg-white"> + <div className="px-6 py-3"> + {/* Breadcrumb */} + <div className="flex items-center justify-between mb-3"> + <Breadcrumb> + <BreadcrumbList> + <BreadcrumbItem> + <BreadcrumbLink href="/evcp/data-room">프로젝트</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + {projectName || '로딩...'} + </BreadcrumbItem> + </BreadcrumbList> + </Breadcrumb> + + <div className="flex items-center gap-2"> + <Badge variant="outline"> + {projectRole || 'viewer'} + </Badge> + <Button variant="outline" size="sm"> + <Share2 className="h-4 w-4 mr-1" /> + 공유 + </Button> + </div> + </div> + + {/* 네비게이션 탭 */} + <div className="flex items-center gap-1"> + {visibleNavItems.map(item => ( + <Button + key={item.label} + variant={item.active ? "secondary" : "ghost"} + size="sm" + onClick={() => router.push(item.href)} + className={cn( + "gap-2", + item.active && "bg-secondary" + )} + > + <item.icon className="h-4 w-4" /> + {item.label} + </Button> + ))} + </div> + </div> + </div> + ); +} + diff --git a/components/project/ProjectSidebar.tsx b/components/project/ProjectSidebar.tsx new file mode 100644 index 00000000..ce2007b1 --- /dev/null +++ b/components/project/ProjectSidebar.tsx @@ -0,0 +1,318 @@ +// components/project/ProjectSidebar.tsx +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { + Home, + FolderOpen, + Users, + Settings, + Plus, + ChevronLeft, + ChevronRight, + Search, + Crown, + Shield, + Eye, + Clock, + Star, + LogOut +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { useSession, signOut } from 'next-auth/react'; + +interface RecentProject { + id: string; + name: string; + role: string; + lastAccessed: string; +} + +export function ProjectSidebar() { + const [collapsed, setCollapsed] = useState(false); + const [recentProjects, setRecentProjects] = useState<RecentProject[]>([]); + const [favoriteProjects, setFavoriteProjects] = useState<string[]>([]); + + const router = useRouter(); + const pathname = usePathname(); + const { data: session } = useSession(); + + const isInternalUser = session?.user?.domain !== 'partners'; + + useEffect(() => { + // 최근 프로젝트 로드 + const stored = localStorage.getItem('recentProjects'); + if (stored) { + setRecentProjects(JSON.parse(stored)); + } + + // 즐겨찾기 프로젝트 로드 + const favorites = localStorage.getItem('favoriteProjects'); + if (favorites) { + setFavoriteProjects(JSON.parse(favorites)); + } + }, [pathname]); + + const menuItems = [ + { + label: '홈', + icon: Home, + href: '/projects', + active: pathname === '/projects', + }, + { + label: '모든 프로젝트', + icon: FolderOpen, + href: '/projects', + active: pathname === '/projects', + }, + ...(isInternalUser ? [{ + label: '팀 관리', + icon: Users, + href: '/projects/team', + active: pathname === '/projects/team', + }] : []), + { + label: '설정', + icon: Settings, + href: '/projects/settings', + active: pathname === '/projects/settings', + }, + ]; + + const roleIcons = { + owner: { icon: Crown, color: 'text-yellow-500' }, + admin: { icon: Shield, color: 'text-blue-500' }, + viewer: { icon: Eye, color: 'text-gray-500' }, + }; + + return ( + <TooltipProvider> + <div className={cn( + "flex flex-col bg-white border-r transition-all duration-300", + collapsed ? "w-16" : "w-64" + )}> + {/* 헤더 */} + <div className="flex items-center justify-between p-4 border-b"> + {!collapsed && ( + <div> + <h2 className="text-lg font-semibold">파일 매니저</h2> + <p className="text-xs text-muted-foreground"> + {session?.user?.name} + </p> + </div> + )} + <Button + variant="ghost" + size="sm" + onClick={() => setCollapsed(!collapsed)} + className={cn(collapsed && "mx-auto")} + > + {collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />} + </Button> + </div> + + {/* 검색 */} + {!collapsed && ( + <div className="p-3 border-b"> + <div className="relative"> + <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="프로젝트 검색..." + className="pl-8 h-8" + /> + </div> + </div> + )} + + {/* 메인 메뉴 */} + <ScrollArea className="flex-1"> + <div className="p-2"> + <div className={cn(!collapsed && "mb-3")}> + {!collapsed && ( + <p className="text-xs text-muted-foreground px-2 mb-2">메뉴</p> + )} + {menuItems.map((item) => ( + <Tooltip key={item.label} delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant={item.active ? "secondary" : "ghost"} + className={cn( + "w-full justify-start mb-1", + collapsed && "justify-center" + )} + onClick={() => router.push(item.href)} + > + <item.icon className={cn("h-4 w-4", !collapsed && "mr-2")} /> + {!collapsed && item.label} + </Button> + </TooltipTrigger> + {collapsed && ( + <TooltipContent side="right"> + {item.label} + </TooltipContent> + )} + </Tooltip> + ))} + </div> + + <Separator className="my-3" /> + + {/* 빠른 액세스 */} + {!collapsed && ( + <div className="mb-3"> + <div className="flex items-center justify-between px-2 mb-2"> + <p className="text-xs text-muted-foreground">빠른 액세스</p> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0" + onClick={() => router.push('/projects/new')} + > + <Plus className="h-3 w-3" /> + </Button> + </div> + + {/* 즐겨찾기 프로젝트 */} + {favoriteProjects.length > 0 && ( + <div className="space-y-1 mb-3"> + {favoriteProjects.slice(0, 3).map((projectId) => ( + <Button + key={projectId} + variant="ghost" + className="w-full justify-start h-8 px-2" + onClick={() => router.push(`/projects/${projectId}/files`)} + > + <Star className="h-3 w-3 mr-2 text-yellow-500" /> + <span className="text-sm truncate">프로젝트 이름</span> + </Button> + ))} + </div> + )} + + {/* 최근 프로젝트 */} + <div className="space-y-1"> + <p className="text-xs text-muted-foreground px-2 mb-1">최근 프로젝트</p> + {recentProjects.slice(0, 5).map((project) => { + const RoleIcon = roleIcons[project.role as keyof typeof roleIcons]; + return ( + <Button + key={project.id} + variant="ghost" + className="w-full justify-start h-8 px-2 group" + onClick={() => router.push(`/projects/${project.id}/files`)} + > + {RoleIcon && ( + <RoleIcon.icon className={cn("h-3 w-3 mr-2", RoleIcon.color)} /> + )} + <span className="text-sm truncate flex-1 text-left"> + {project.name} + </span> + <Clock className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100" /> + </Button> + ); + })} + </div> + </div> + )} + + {collapsed && ( + <div className="space-y-1"> + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className="w-full justify-center" + onClick={() => router.push('/projects/new')} + > + <Plus className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + 새 프로젝트 + </TooltipContent> + </Tooltip> + + {recentProjects.slice(0, 3).map((project) => { + const RoleIcon = roleIcons[project.role as keyof typeof roleIcons]; + return ( + <Tooltip key={project.id} delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className="w-full justify-center" + onClick={() => router.push(`/projects/${project.id}/files`)} + > + {RoleIcon && ( + <RoleIcon.icon className={cn("h-4 w-4", RoleIcon.color)} /> + )} + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {project.name} + </TooltipContent> + </Tooltip> + ); + })} + </div> + )} + </div> + </ScrollArea> + + {/* 하단 사용자 정보 */} + <div className="border-t p-3"> + {!collapsed ? ( + <div className="flex items-center gap-2"> + <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center"> + <span className="text-xs font-medium"> + {session?.user?.name?.charAt(0).toUpperCase()} + </span> + </div> + <div className="flex-1"> + <p className="text-sm font-medium truncate">{session?.user?.name}</p> + <Badge variant="outline" className="text-xs"> + {isInternalUser ? '내부' : '외부'} + </Badge> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => signOut()} + > + <LogOut className="h-4 w-4" /> + </Button> + </div> + ) : ( + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className="w-full justify-center" + onClick={() => signOut()} + > + <LogOut className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + 로그아웃 + </TooltipContent> + </Tooltip> + )} + </div> + </div> + </TooltipProvider> + ); +} + diff --git a/db/schema/fileSystem.ts b/db/schema/fileSystem.ts new file mode 100644 index 00000000..a66e3180 --- /dev/null +++ b/db/schema/fileSystem.ts @@ -0,0 +1,377 @@ +// db/schema/fileSystem.ts +import { + pgTable, + varchar, + integer, + timestamp, + boolean, + text, + jsonb, + uuid, + bigint, + uniqueIndex, + index, + pgEnum, + primaryKey, +} from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { users } from "./users"; // 기존 users 테이블 + +// 파일 접근 레벨 Enum +export const fileAccessLevelEnum = pgEnum("file_access_level", [ + "view_only", // 열람만 가능 + "view_download", // 열람 + 다운로드 + "full_access", // 모든 권한 (내부 사용자 기본) +]); + +// 파일 타입 Enum +export const fileTypeEnum = pgEnum("file_type", ["file", "folder"]); + +// 파일 카테고리 Enum (외부 사용자 접근 권한 분류) +export const fileCategoryEnum = pgEnum("file_category", [ + "public", // 외부 사용자 열람 + 다운로드 가능 + "restricted", // 외부 사용자 열람만 가능 + "confidential", // 외부 사용자 접근 불가 + "internal", // 내부 전용 +]); + +// 프로젝트 테이블 +export const fileSystemProjects = pgTable("file_system_projects", { + id: uuid("id").primaryKey().defaultRandom(), + code: varchar("code", { length: 50 }).notNull(), + + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + ownerId: integer("owner_id") + .references(() => users.id, { onDelete: "set null" }), + isPublic: boolean("is_public").default(false).notNull(), // 외부 공개 여부 + externalAccessEnabled: boolean("external_access_enabled").default(false).notNull(), + metadata: jsonb("metadata").default({}).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + ownerIdx: index("projects_owner_idx").on(table.ownerId), +})); + +// 파일/폴더 테이블 +export const fileItems = pgTable("file_items", { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => fileSystemProjects.id, { onDelete: "cascade" }) + .notNull(), + parentId: uuid("parent_id") + .references(() => fileItems.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + type: fileTypeEnum("type").notNull(), + + // 파일 정보 + mimeType: varchar("mime_type", { length: 255 }), + size: bigint("size", { mode: "number" }).default(0).notNull(), + filePath: text("file_path"), // S3 키 또는 로컬 경로 + fileUrl: text("file_url"), // 직접 접근 URL (CDN 등) + + // 권한 카테고리 (외부 사용자용) + category: fileCategoryEnum("category").default("confidential").notNull(), + + // 외부 접근 설정 + externalAccessLevel: fileAccessLevelEnum("external_access_level").default("view_only"), + externalAccessExpiry: timestamp("external_access_expiry", { withTimezone: true }), + downloadCount: integer("download_count").default(0).notNull(), + viewCount: integer("view_count").default(0).notNull(), + + // 메타데이터 + metadata: jsonb("metadata").default({}).notNull(), + tags: text("tags").array(), // 태그 배열 + + // 버전 관리 + version: integer("version").default(1).notNull(), + previousVersionId: uuid("previous_version_id"), + + // 감사 로그 + createdBy: integer("created_by") + .references(() => users.id, { onDelete: "set null" }), + updatedBy: integer("updated_by") + .references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + + // 경로 최적화 + path: text("path").notNull().default("/"), + depth: integer("depth").notNull().default(0), +}, (table) => ({ + projectPathIdx: uniqueIndex("file_items_project_path_idx").on( + table.projectId, + table.path, + table.name + ), + parentIdx: index("file_items_parent_idx").on(table.parentId), + categoryIdx: index("file_items_category_idx").on(table.category), + createdByIdx: index("file_items_created_by_idx").on(table.createdBy), + tagsIdx: index("file_items_tags_idx").on(table.tags), +})); + +// 파일 공유 링크 테이블 +export const fileShares = pgTable("file_shares", { + id: uuid("id").primaryKey().defaultRandom(), + fileItemId: uuid("file_item_id") + .references(() => fileItems.id, { onDelete: "cascade" }) + .notNull(), + shareToken: varchar("share_token", { length: 64 }).notNull().unique(), + + // 공유 설정 + accessLevel: fileAccessLevelEnum("access_level").default("view_only").notNull(), + password: varchar("password", { length: 255 }), // 선택적 비밀번호 + maxDownloads: integer("max_downloads"), // 최대 다운로드 횟수 + currentDownloads: integer("current_downloads").default(0).notNull(), + + // 유효기간 + expiresAt: timestamp("expires_at", { withTimezone: true }), + + // 공유 대상 (선택적) + sharedWithEmail: varchar("shared_with_email", { length: 255 }), + sharedWithUserId: integer("shared_with_user_id") + .references(() => users.id, { onDelete: "set null" }), + + // 감사 로그 + createdBy: integer("created_by") + .references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + lastAccessedAt: timestamp("last_accessed_at", { withTimezone: true }), +}, (table) => ({ + tokenIdx: uniqueIndex("file_shares_token_idx").on(table.shareToken), + fileIdx: index("file_shares_file_idx").on(table.fileItemId), + expiryIdx: index("file_shares_expiry_idx").on(table.expiresAt), +})); + +// 세밀한 파일 권한 테이블 (특정 사용자/그룹에 대한 예외 권한) +export const filePermissions = pgTable("file_permissions", { + id: uuid("id").primaryKey().defaultRandom(), + fileItemId: uuid("file_item_id") + .references(() => fileItems.id, { onDelete: "cascade" }) + .notNull(), + + // 대상 (사용자 또는 도메인) + userId: integer("user_id") + .references(() => users.id, { onDelete: "cascade" }), + userDomain: varchar("user_domain", { length: 50 }), // 'partners', 'internal' 등 + + // 권한 + canView: boolean("can_view").default(true).notNull(), + canDownload: boolean("can_download").default(false).notNull(), + canEdit: boolean("can_edit").default(false).notNull(), + canDelete: boolean("can_delete").default(false).notNull(), + canShare: boolean("can_share").default(false).notNull(), + + // 유효기간 + validFrom: timestamp("valid_from", { withTimezone: true }), + validUntil: timestamp("valid_until", { withTimezone: true }), + + // 감사 로그 + grantedBy: integer("granted_by") + .references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + fileUserIdx: uniqueIndex("file_permissions_file_user_idx").on( + table.fileItemId, + table.userId + ), + fileIdx: index("file_permissions_file_idx").on(table.fileItemId), + userIdx: index("file_permissions_user_idx").on(table.userId), + domainIdx: index("file_permissions_domain_idx").on(table.userDomain), +})); + +// 파일 활동 로그 테이블 +export const fileActivityLogs = pgTable("file_activity_logs", { + id: uuid("id").primaryKey().defaultRandom(), + fileItemId: uuid("file_item_id") + .references(() => fileItems.id, { onDelete: "cascade" }) + .notNull(), + projectId: uuid("project_id") + .references(() => fileSystemProjects.id, { onDelete: "cascade" }) + .notNull(), + + // 활동 정보 + action: varchar("action", { length: 50 }).notNull(), // 'view', 'download', 'upload', 'edit', 'delete', 'share' + actionDetails: jsonb("action_details").default({}).notNull(), + + // 사용자 정보 + userId: integer("user_id") + .references(() => users.id, { onDelete: "set null" }), + userEmail: varchar("user_email", { length: 255 }), + userDomain: varchar("user_domain", { length: 50 }), + ipAddress: varchar("ip_address", { length: 45 }), + userAgent: text("user_agent"), + + // 공유 링크를 통한 접근인 경우 + shareId: uuid("share_id") + .references(() => fileShares.id, { onDelete: "set null" }), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + fileIdx: index("file_activity_logs_file_idx").on(table.fileItemId), + projectIdx: index("file_activity_logs_project_idx").on(table.projectId), + userIdx: index("file_activity_logs_user_idx").on(table.userId), + actionIdx: index("file_activity_logs_action_idx").on(table.action), + createdAtIdx: index("file_activity_logs_created_at_idx").on(table.createdAt), +})); + +// Relations +export const projectsFilesRelations = relations(fileSystemProjects, ({ one, many }) => ({ + owner: one(users, { + fields: [fileSystemProjects.ownerId], + references: [users.id], + }), + fileItems: many(fileItems), +})); + + +export const fileItemsRelations = relations(fileItems, ({ one, many }) => ({ + project: one(fileSystemProjects, { + fields: [fileItems.projectId], + references: [fileSystemProjects.id], + }), + parent: one(fileItems, { + fields: [fileItems.parentId], + references: [fileItems.id], + relationName: "parentChild", + }), + children: many(fileItems, { + relationName: "parentChild", + }), + createdByUser: one(users, { + fields: [fileItems.createdBy], + references: [users.id], + relationName: "createdFiles", + }), + updatedByUser: one(users, { + fields: [fileItems.updatedBy], + references: [users.id], + relationName: "updatedFiles", + }), + permissions: many(filePermissions), + shares: many(fileShares), + activityLogs: many(fileActivityLogs), +})); + +export const filePermissionsRelations = relations(filePermissions, ({ one }) => ({ + fileItem: one(fileItems, { + fields: [filePermissions.fileItemId], + references: [fileItems.id], + }), + user: one(users, { + fields: [filePermissions.userId], + references: [users.id], + }), + grantedByUser: one(users, { + fields: [filePermissions.grantedBy], + references: [users.id], + relationName: "grantedPermissions", + }), +})); + +export const fileSharesRelations = relations(fileShares, ({ one }) => ({ + fileItem: one(fileItems, { + fields: [fileShares.fileItemId], + references: [fileItems.id], + }), + createdByUser: one(users, { + fields: [fileShares.createdBy], + references: [users.id], + }), + sharedWithUser: one(users, { + fields: [fileShares.sharedWithUserId], + references: [users.id], + relationName: "receivedShares", + }), +})); + +export const fileActivityLogsRelations = relations(fileActivityLogs, ({ one }) => ({ + fileItem: one(fileItems, { + fields: [fileActivityLogs.fileItemId], + references: [fileItems.id], + }), + project: one(fileSystemProjects, { + fields: [fileActivityLogs.projectId], + references: [fileSystemProjects.id], + }), + user: one(users, { + fields: [fileActivityLogs.userId], + references: [users.id], + }), + share: one(fileShares, { + fields: [fileActivityLogs.shareId], + references: [fileShares.id], + }), +})); + +// Type exports +export type FileItem = typeof fileItems.$inferSelect; +export type NewFileItem = typeof fileItems.$inferInsert; +export type FilePermission = typeof filePermissions.$inferSelect; +export type NewFilePermission = typeof filePermissions.$inferInsert; +export type FileShare = typeof fileShares.$inferSelect; +export type NewFileShare = typeof fileShares.$inferInsert; +export type FileActivityLog = typeof fileActivityLogs.$inferSelect; +export type NewFileActivityLog = typeof fileActivityLogs.$inferInsert; + + + + +export type FileSystemProject = typeof fileSystemProjects.$inferSelect; +export type NewFileSystemProject = typeof fileSystemProjects.$inferInsert; + +// db/schema/fileSystem.ts에 추가할 테이블 +export const projectMemberRoleEnum = pgEnum("project_member_role", [ + "owner", + "admin", + "editor", + "viewer", +]); + +export const projectMembers = pgTable("project_members", { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => fileSystemProjects.id, { onDelete: "cascade" }) + .notNull(), + userId: integer("user_id") + .references(() => users.id, { onDelete: "cascade" }) + .notNull(), + role: projectMemberRoleEnum("role").notNull().default("viewer"), + + // 권한 세부 설정 (역할 외 추가 권한) + canInvite: boolean("can_invite").default(false).notNull(), + canManageFiles: boolean("can_manage_files").default(false).notNull(), + canManageMembers: boolean("can_manage_members").default(false).notNull(), + + // 감사 로그 + addedBy: integer("added_by") + .references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + // 한 프로젝트에 한 사용자는 하나의 역할만 + projectUserUnique: uniqueIndex("project_members_project_user_idx").on( + table.projectId, + table.userId + ), + userIdx: index("project_members_user_idx").on(table.userId), + roleIdx: index("project_members_role_idx").on(table.role), +})); + +// Relations 추가 +export const projectMembersRelations = relations(projectMembers, ({ one }) => ({ + project: one(fileSystemProjects, { + fields: [projectMembers.projectId], + references: [fileSystemProjects.id], + }), + user: one(users, { + fields: [projectMembers.userId], + references: [users.id], + }), + addedByUser: one(users, { + fields: [projectMembers.addedBy], + references: [users.id], + relationName: "addedMembers", + }), +}));
\ No newline at end of file diff --git a/db/schema/index.ts b/db/schema/index.ts index 2bbdb267..a223f0de 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -44,6 +44,8 @@ export * from './generalContract'; export * from './rfqLastTBE'; export * from './pcr'; +export * from './fileSystem'; + // 부서별 도메인 할당 관리 export * from './departmentDomainAssignments'; diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts index c8417901..61545d95 100644 --- a/lib/gtc-contract/service.ts +++ b/lib/gtc-contract/service.ts @@ -319,6 +319,7 @@ export async function getUsersForFilter(): Promise<UserForFilter[]> { id: users.id, name: users.name, email: users.email, + domain: users.domain, }) .from(users) .where(eq(users.isActive, true)) // 활성 사용자만 diff --git a/lib/itb/service.ts b/lib/itb/service.ts index f649bdf5..181285cc 100644 --- a/lib/itb/service.ts +++ b/lib/itb/service.ts @@ -3,7 +3,7 @@ import db from "@/db/db"; import { purchaseRequestsView, purchaseRequests, purchaseRequestAttachments, rfqsLast, rfqLastAttachments, rfqLastAttachmentRevisions, rfqPrItems, users } from "@/db/schema"; -import { eq, and, desc, ilike, or, sql, asc, inArray ,like} from "drizzle-orm"; +import { eq, and, desc, ilike, or, sql, asc, inArray, like } from "drizzle-orm"; import { revalidatePath, revalidateTag } from "next/cache"; import { getServerSession } from 'next-auth/next' import { authOptions } from '@/app/api/auth/[...nextauth]/route' @@ -293,7 +293,7 @@ export async function approvePurchaseRequestAndCreateRfq( .where(eq(purchaseRequestAttachments.requestId, requestId)); - const rfqCode = await generateItbRfqCode(purchasePicId); + const rfqCode = await generateItbRfqCode(purchasePicId); const [rfq] = await tx.insert(rfqsLast).values({ rfqCode, @@ -547,10 +547,10 @@ export async function getPurchaseRequestAttachments(requestId: number) { } } catch (error) { console.error("Get attachments error:", error) - return { + return { success: false, error: "첨부파일 조회 중 오류가 발생했습니다.", - data: [] + data: [] } } } @@ -558,223 +558,226 @@ export async function getPurchaseRequestAttachments(requestId: number) { export async function generateItbRfqCode(purchasePicId?: number): Promise<string> { try { - let userCode = "???"; - - // purchasePicId가 있으면 users 테이블에서 userCode 조회 - if (purchasePicId) { - const [user] = await db - .select({ userCode: users.userCode }) - .from(users) - .where(eq(users.id, purchasePicId)) - .limit(1); - - if (user?.userCode) { - userCode = user.userCode; + let userCode = "???"; + + // purchasePicId가 있으면 users 테이블에서 userCode 조회 + if (purchasePicId) { + const [user] = await db + .select({ userCode: users.userCode }) + .from(users) + .where(eq(users.id, purchasePicId)) + .limit(1); + + if (user?.userCode) { + userCode = user.userCode; + } } - } - - // 동일한 userCode로 시작하는 마지막 RFQ 조회 - const lastRfq = await db - .select({ rfqCode: rfqsLast.rfqCode }) - .from(rfqsLast) - .where(like(rfqsLast.rfqCode, `I${userCode}%`)) - .orderBy(desc(rfqsLast.createdAt)) - .limit(1); - - let nextNumber = 1; - - if (lastRfq.length > 0 && lastRfq[0].rfqCode) { - const rfqCode = lastRfq[0].rfqCode; - const serialNumber = rfqCode.slice(-5); // 마지막 5자리 - - if (/^\d{5}$/.test(serialNumber)) { - nextNumber = parseInt(serialNumber) + 1; + + // 동일한 userCode로 시작하는 마지막 RFQ 조회 + const lastRfq = await db + .select({ rfqCode: rfqsLast.rfqCode }) + .from(rfqsLast) + .where(like(rfqsLast.rfqCode, `I${userCode}%`)) + .orderBy(desc(rfqsLast.createdAt)) + .limit(1); + + let nextNumber = 1; + + if (lastRfq.length > 0 && lastRfq[0].rfqCode) { + const rfqCode = lastRfq[0].rfqCode; + const serialNumber = rfqCode.slice(-5); // 마지막 5자리 + + if (/^\d{5}$/.test(serialNumber)) { + nextNumber = parseInt(serialNumber) + 1; + } } - } - - const paddedNumber = String(nextNumber).padStart(5, "0"); - - return `I${userCode}${paddedNumber}`; + + const paddedNumber = String(nextNumber).padStart(5, "0"); + + return `I${userCode}${paddedNumber}`; } catch (error) { - console.error("Error generating ITB RFQ code:", error); - const fallback = Date.now().toString().slice(-5); - return `I???${fallback}`; + console.error("Error generating ITB RFQ code:", error); + const fallback = Date.now().toString().slice(-5); + return `I???${fallback}`; } - } - +} + - // lib/purchase-requests/service.ts에 추가 +// lib/purchase-requests/service.ts에 추가 // 여러 구매 요청 승인 및 RFQ 생성 export async function approvePurchaseRequestsAndCreateRfqs( requestIds: number[], purchasePicId?: number - ) { +) { try { - const session = await getServerSession(authOptions) - if (!session?.user?.id) throw new Error("Unauthorized"); - const userId = Number(session.user.id) - - const results = [] - - for (const requestId of requestIds) { - try { - const result = await db.transaction(async (tx) => { - // 구매 요청 조회 - const [request] = await tx - .select() - .from(purchaseRequests) - .where(eq(purchaseRequests.id, requestId)) - - if (!request) { - throw new Error(`구매 요청 ${requestId}를 찾을 수 없습니다.`) - } - - if (request.status === "RFQ생성완료") { - return { skipped: true, requestId, message: "이미 RFQ가 생성되었습니다." } - } - - const attachments = await tx - .select() - .from(purchaseRequestAttachments) - .where(eq(purchaseRequestAttachments.requestId, requestId)) - - const rfqCode = await generateItbRfqCode(purchasePicId) - - // 마감일 기본값 설정 (입력값 없으면 생성일 + 7일) - const defaultDueDate = getDefaultDueDate(); - - const [rfq] = await tx - .insert(rfqsLast) - .values({ - rfqCode, - projectId: request.projectId, - itemCode: request.items?.[0]?.itemCode, - itemName: request.items?.[0]?.itemName, - packageNo: request.packageNo, - packageName: request.packageName, - EngPicName: request.engPicName, - pic: purchasePicId || null, - status: "RFQ 생성", - dueDate: defaultDueDate, // 마감일 기본값 설정 - projectCompany: request.projectCompany, - projectSite: request.projectSite, - smCode: request.smCode, - createdBy: userId, - updatedBy: userId, - }) - .returning() - - // 첨부파일 이관 - for (const [index, attachment] of attachments.entries()) { - const [rfqAttachment] = await tx - .insert(rfqLastAttachments) - .values({ - attachmentType: "설계", - serialNo: `ENG-${String(index + 1).padStart(3, "0")}`, - rfqId: rfq.id, - description: - attachment.description || - `설계문서 - ${attachment.originalFileName}`, - currentRevision: "Rev.0", - createdBy: userId, - }) - .returning() - - const [revision] = await tx - .insert(rfqLastAttachmentRevisions) - .values({ - attachmentId: rfqAttachment.id, - revisionNo: "Rev.0", - revisionComment: "구매 요청에서 이관된 설계 문서", - isLatest: true, - fileName: attachment.fileName, - originalFileName: attachment.originalFileName, - filePath: attachment.filePath, - fileSize: attachment.fileSize, - fileType: attachment.fileType, - createdBy: userId, + const session = await getServerSession(authOptions) + if (!session?.user?.id) throw new Error("Unauthorized"); + const userId = Number(session.user.id) + + const results = [] + + for (const requestId of requestIds) { + try { + const result = await db.transaction(async (tx) => { + // 구매 요청 조회 + const [request] = await tx + .select() + .from(purchaseRequests) + .where(eq(purchaseRequests.id, requestId)) + + if (!request) { + throw new Error(`구매 요청 ${requestId}를 찾을 수 없습니다.`) + } + + if (request.status === "RFQ생성완료") { + return { skipped: true, requestId, message: "이미 RFQ가 생성되었습니다." } + } + + const attachments = await tx + .select() + .from(purchaseRequestAttachments) + .where(eq(purchaseRequestAttachments.requestId, requestId)) + + const rfqCode = await generateItbRfqCode(purchasePicId) + + const defaultDueDate = (() => { + const d = new Date(); + d.setDate(d.getDate() + 15); + return d; + })(); + + + const [rfq] = await tx + .insert(rfqsLast) + .values({ + rfqCode, + projectId: request.projectId, + itemCode: request.items?.[0]?.itemCode, + itemName: request.items?.[0]?.itemName, + packageNo: request.packageNo, + packageName: request.packageName, + EngPicName: request.engPicName, + pic: purchasePicId || null, + status: "RFQ 생성", + dueDate: defaultDueDate, // 마감일 기본값 설정 + projectCompany: request.projectCompany, + projectSite: request.projectSite, + smCode: request.smCode, + createdBy: userId, + updatedBy: userId, + }) + .returning() + + // 첨부파일 이관 + for (const [index, attachment] of attachments.entries()) { + const [rfqAttachment] = await tx + .insert(rfqLastAttachments) + .values({ + attachmentType: "설계", + serialNo: `ENG-${String(index + 1).padStart(3, "0")}`, + rfqId: rfq.id, + description: + attachment.description || + `설계문서 - ${attachment.originalFileName}`, + currentRevision: "Rev.0", + createdBy: userId, + }) + .returning() + + const [revision] = await tx + .insert(rfqLastAttachmentRevisions) + .values({ + attachmentId: rfqAttachment.id, + revisionNo: "Rev.0", + revisionComment: "구매 요청에서 이관된 설계 문서", + isLatest: true, + fileName: attachment.fileName, + originalFileName: attachment.originalFileName, + filePath: attachment.filePath, + fileSize: attachment.fileSize, + fileType: attachment.fileType, + createdBy: userId, + }) + .returning() + + await tx + .update(rfqLastAttachments) + .set({ latestRevisionId: revision.id }) + .where(eq(rfqLastAttachments.id, rfqAttachment.id)) + } + + // 품목 이관 + if (request.items && request.items.length > 0) { + console.log("🚀 품목 이관 시작:", { + requestId, + itemsCount: request.items.length, + items: request.items + }); + + const prItemsData = request.items.map((item, index) => ({ + rfqsLastId: rfq.id, + rfqItem: `${index + 1}`.padStart(3, '0'), + prItem: `${index + 1}`.padStart(3, '0'), + prNo: rfqCode, + materialCategory: request.majorItemMaterialCategory, + materialCode: item.itemCode, + materialDescription: item.itemName, + quantity: item.quantity, + uom: item.unit, + majorYn: index === 0, + remark: item.remarks || null, + })); + + console.log("🔍 삽입할 데이터:", prItemsData); + + const insertedItems = await tx.insert(rfqPrItems).values(prItemsData).returning(); + console.log("✅ 품목 이관 완료:", insertedItems); + } else { + console.log("❌ 품목이 없음:", { + requestId, + hasItems: !!request.items, + itemsLength: request.items?.length || 0 + }); + } + + // 구매 요청 상태 업데이트 + await tx + .update(purchaseRequests) + .set({ + status: "RFQ생성완료", + rfqId: rfq.id, + rfqCode: rfq.rfqCode, + rfqCreatedAt: new Date(), + purchasePicId, + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(purchaseRequests.id, requestId)) + + return { success: true, rfq, requestId } }) - .returning() - - await tx - .update(rfqLastAttachments) - .set({ latestRevisionId: revision.id }) - .where(eq(rfqLastAttachments.id, rfqAttachment.id)) - } - - // 품목 이관 - if (request.items && request.items.length > 0) { - console.log("🚀 품목 이관 시작:", { - requestId, - itemsCount: request.items.length, - items: request.items - }); - - const prItemsData = request.items.map((item, index) => ({ - rfqsLastId: rfq.id, - rfqItem: `${index + 1}`.padStart(3, '0'), - prItem: `${index + 1}`.padStart(3, '0'), - prNo: rfqCode, - materialCategory:request.majorItemMaterialCategory, - materialCode: item.itemCode, - materialDescription: item.itemName, - quantity: item.quantity, - uom: item.unit, - majorYn: index === 0, - remark: item.remarks || null, - })); - - console.log("🔍 삽입할 데이터:", prItemsData); - - const insertedItems = await tx.insert(rfqPrItems).values(prItemsData).returning(); - console.log("✅ 품목 이관 완료:", insertedItems); - } else { - console.log("❌ 품목이 없음:", { + + results.push(result) + } catch (err: any) { + console.error(`구매 요청 ${requestId} 처리 중 오류:`, err) + results.push({ + success: false, requestId, - hasItems: !!request.items, - itemsLength: request.items?.length || 0 - }); + error: err.message || "알 수 없는 오류 발생", + }) } - - // 구매 요청 상태 업데이트 - await tx - .update(purchaseRequests) - .set({ - status: "RFQ생성완료", - rfqId: rfq.id, - rfqCode: rfq.rfqCode, - rfqCreatedAt: new Date(), - purchasePicId, - updatedBy: userId, - updatedAt: new Date(), - }) - .where(eq(purchaseRequests.id, requestId)) - - return { success: true, rfq, requestId } - }) - - results.push(result) - } catch (err: any) { - console.error(`구매 요청 ${requestId} 처리 중 오류:`, err) - results.push({ - success: false, - requestId, - error: err.message || "알 수 없는 오류 발생", - }) } - } - - // 캐시 무효화 - revalidateTag("purchase-requests") - revalidateTag( "purchase-request-stats") - - revalidateTag("rfqs") - - return results + + // 캐시 무효화 + revalidateTag("purchase-requests") + revalidateTag("purchase-request-stats") + + revalidateTag("rfqs") + + return results } catch (err: any) { - console.error("approvePurchaseRequestsAndCreateRfqs 실행 오류:", err) - throw new Error(err.message || "구매 요청 처리 중 오류가 발생했습니다.") + console.error("approvePurchaseRequestsAndCreateRfqs 실행 오류:", err) + throw new Error(err.message || "구매 요청 처리 중 오류가 발생했습니다.") } - } -
\ No newline at end of file +} diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 82f8837a..be8e13e6 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -357,6 +357,8 @@ export async function createGeneralRfqAction(input: CreateGeneralRfqInput) { // 5. 마감일 기본값 설정 (입력값 없으면 생성일 + 7일) const dueDate = input.dueDate || getDefaultDueDate(); + console.log(dueDate,"dueDate") + // 6. rfqsLast 테이블에 기본 정보 삽입 const [newRfq] = await tx .insert(rfqsLast) @@ -3798,8 +3800,8 @@ export async function updateRfqDueDate( } // 6. 각 vendor별로 이메일 발송 - const emailPromises = [] - + const emailPromises: Promise<any>[] = [] + for (const detail of rfqDetailsData) { if (!detail.emailSentTo) continue diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx index c9790880..4a8960ff 100644 --- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx +++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx @@ -53,6 +53,8 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp const currency = watch("vendorCurrency") || "USD" const quotationItems = watch("quotationItems") + + console.log(prItems,"prItems") // PR 아이템 정보를 quotationItems에 초기화 useEffect(() => { diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx index 6da704cd..569546dd 100644 --- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx +++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx @@ -19,6 +19,22 @@ import { Shield, FileText, CheckCircle, XCircle, Clock, Download, Eye, Save, Sen import { Progress } from "@/components/ui/progress" import { Alert, AlertDescription } from "@/components/ui/alert" + +const quotationItemSchema = z.object({ + rfqPrItemId: z.number(), + unitPrice: z.number().min(0), + totalPrice: z.number().min(0), + vendorDeliveryDate: z.date().optional().nullable(), + leadTime: z.number().optional(), + manufacturer: z.string().optional(), + manufacturerCountry: z.string().optional(), + modelNo: z.string().optional(), + technicalCompliance: z.boolean(), + alternativeProposal: z.string().optional(), + discountRate: z.number().optional(), + itemRemark: z.string().optional(), + deviationReason: z.string().optional(), +}).passthrough(); // ⬅️ 여기가 핵심: 정의 안 된 키도 유지 // 폼 스키마 정의 const vendorResponseSchema = z.object({ // 상업 조건 @@ -59,21 +75,7 @@ const vendorResponseSchema = z.object({ technicalProposal: z.string().optional(), // 견적 아이템 - quotationItems: z.array(z.object({ - rfqPrItemId: z.number(), - unitPrice: z.number().min(0), - totalPrice: z.number().min(0), - vendorDeliveryDate: z.date().optional().nullable(), - leadTime: z.number().optional(), - manufacturer: z.string().optional(), - manufacturerCountry: z.string().optional(), - modelNo: z.string().optional(), - technicalCompliance: z.boolean(), - alternativeProposal: z.string().optional(), - discountRate: z.number().optional(), - itemRemark: z.string().optional(), - deviationReason: z.string().optional(), - })) +quotationItems: z.array(quotationItemSchema), }) type VendorResponseFormData = z.infer<typeof vendorResponseSchema> @@ -104,6 +106,8 @@ export default function VendorResponseEditor({ const [attachments, setAttachments] = useState<File[]>([]) const [uploadProgress, setUploadProgress] = useState(0) // 추가 + console.log(existingResponse,"existingResponse") + // Form 초기값 설정 const defaultValues: VendorResponseFormData = { @@ -175,6 +179,8 @@ export default function VendorResponseEditor({ } }, [errors]) + console.log(methods.getValues()) + const handleFormSubmit = (isSubmit: boolean = false) => { diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 98d53f5d..ef906ed6 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -1206,7 +1206,7 @@ export function RfqVendorTable({ <Button variant="ghost" size="sm" - onClick={() => handleAction("response-detail", row.original)} + onClick={() => handleAction("view", row.original)} className="h-7 px-2" > <Eye className="h-3 w-3 mr-1" /> diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx index 54aada1d..17eed54c 100644 --- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx +++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx @@ -148,8 +148,8 @@ export function VendorResponseDetailDialog({ return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto"> - <DialogHeader> + <DialogContent className="max-w-5xl max-h-[90vh] p-0 flex flex-col"> + <DialogHeader className="flex-shrink-0 sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b px-6 py-4"> <div className="flex items-center justify-between"> <div> <DialogTitle className="text-xl font-bold"> @@ -170,7 +170,9 @@ export function VendorResponseDetailDialog({ </div> </DialogHeader> - <Tabs defaultValue="overview" className="mt-4"> + <div className="flex-1 overflow-y-auto px-6 "> + + <Tabs defaultValue="overview" className="mb-2"> <TabsList className="grid w-full grid-cols-4"> <TabsTrigger value="overview">개요</TabsTrigger> <TabsTrigger value="quotation">견적정보</TabsTrigger> @@ -689,6 +691,8 @@ export function VendorResponseDetailDialog({ )} </TabsContent> </Tabs> + </div> + </DialogContent> </Dialog> ); diff --git a/lib/services/fileService.ts b/lib/services/fileService.ts new file mode 100644 index 00000000..56966a86 --- /dev/null +++ b/lib/services/fileService.ts @@ -0,0 +1,516 @@ +// lib/services/fileService.ts +import db from "@/db/db"; +import { + fileItems, + filePermissions, + fileShares, + fileActivityLogs, + projects, + type FileItem, + type NewFileItem, + type FilePermission, +} from "@/db/schema/fileSystem"; +import { users } from "@/db/schema/users"; +import { eq, and, or, isNull, lte, gte, sql, inArray } from "drizzle-orm"; +import crypto from "crypto"; + +export interface FileAccessContext { + userId: number; + userDomain: string; + userEmail: string; + ipAddress?: string; + userAgent?: string; +} + +export class FileService { + // 사용자가 내부 사용자인지 확인 + private isInternalUser(domain: string): boolean { + // partners가 아닌 경우 내부 사용자로 간주 + return domain !== "partners"; + } + + // 파일 접근 권한 확인 + async checkFileAccess( + fileId: string, + context: FileAccessContext, + requiredAction: "view" | "download" | "edit" | "delete" | "share" + ): Promise<boolean> { + // 내부 사용자는 모든 권한 보유 + if (this.isInternalUser(context.userDomain)) { + return true; + } + + // 파일 정보 조회 + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, fileId), + }); + + if (!file) return false; + + // 외부 사용자 권한 체크 + // 1. 파일 카테고리별 기본 권한 체크 + switch (file.category) { + case "public": + // public 파일은 열람과 다운로드 가능 + if (requiredAction === "view" || requiredAction === "download") { + return true; + } + break; + case "restricted": + // restricted 파일은 열람만 가능 + if (requiredAction === "view") { + return true; + } + break; + case "confidential": + case "internal": + // 기본적으로 접근 불가 + break; + } + + // 2. 개별 권한 설정 체크 + const permission = await db.query.filePermissions.findFirst({ + where: and( + eq(filePermissions.fileItemId, fileId), + or( + eq(filePermissions.userId, context.userId), + eq(filePermissions.userDomain, context.userDomain) + ), + or( + isNull(filePermissions.validFrom), + lte(filePermissions.validFrom, new Date()) + ), + or( + isNull(filePermissions.validUntil), + gte(filePermissions.validUntil, new Date()) + ) + ), + }); + + if (permission) { + switch (requiredAction) { + case "view": return permission.canView; + case "download": return permission.canDownload; + case "edit": return permission.canEdit; + case "delete": return permission.canDelete; + case "share": return permission.canShare; + } + } + + return false; + } + + // 파일 목록 조회 (트리 뷰 지원) +async getFileList( + projectId: string, + parentId: string | null, + context: FileAccessContext, + options?: { + includeAll?: boolean; // 전체 파일 가져오기 옵션 + } +) { + const isInternal = this.isInternalUser(context.userDomain); + + // 기본 쿼리 빌드 + let baseConditions = [eq(fileItems.projectId, projectId)]; + + // includeAll이 false이거나 명시되지 않은 경우에만 parentId 조건 추가 + if (!options?.includeAll) { + baseConditions.push( + parentId ? eq(fileItems.parentId, parentId) : isNull(fileItems.parentId) + ); + } + + let query = db + .select({ + file: fileItems, + canView: sql<boolean>`true`, + canDownload: sql<boolean>`${isInternal}`, + canEdit: sql<boolean>`${isInternal}`, + canDelete: sql<boolean>`${isInternal}`, + }) + .from(fileItems) + .where(and(...baseConditions)); + + if (!isInternal) { + // 외부 사용자는 접근 가능한 파일만 표시 + let externalConditions = [eq(fileItems.projectId, projectId)]; + + if (!options?.includeAll) { + externalConditions.push( + parentId ? eq(fileItems.parentId, parentId) : isNull(fileItems.parentId) + ); + } + + query = db + .select({ + file: fileItems, + canView: sql<boolean>` + CASE + WHEN ${fileItems.category} IN ('public', 'restricted') THEN true + WHEN ${filePermissions.canView} = true THEN true + ELSE false + END + `, + canDownload: sql<boolean>` + CASE + WHEN ${fileItems.category} = 'public' THEN true + WHEN ${filePermissions.canDownload} = true THEN true + ELSE false + END + `, + canEdit: sql<boolean>`COALESCE(${filePermissions.canEdit}, false)`, + canDelete: sql<boolean>`COALESCE(${filePermissions.canDelete}, false)`, + }) + .from(fileItems) + .leftJoin( + filePermissions, + and( + eq(filePermissions.fileItemId, fileItems.id), + or( + eq(filePermissions.userId, context.userId), + eq(filePermissions.userDomain, context.userDomain) + ) + ) + ) + .where( + and( + ...externalConditions, + or( + inArray(fileItems.category, ["public", "restricted"]), + eq(filePermissions.canView, true) + ) + ) + ); + } + + const results = await query; + + // 활동 로그 기록 (전체 목록 조회시에는 로그 생략) + if (!options?.includeAll) { + for (const result of results) { + await this.logActivity(result.file.id, projectId, "view", context); + } + } + + return results.map(r => ({ + ...r.file, + permissions: { + canView: r.canView, + canDownload: r.canDownload, + canEdit: r.canEdit, + canDelete: r.canDelete, + }, + })); +} + + + // 파일/폴더 생성 + async createFileItem( + data: NewFileItem, + context: FileAccessContext + ): Promise<FileItem> { + // 내부 사용자만 파일 생성 가능 + if (!this.isInternalUser(context.userDomain)) { + throw new Error("권한이 없습니다"); + } + + // 경로 계산 + let path = "/"; + let depth = 0; + + if (data.parentId) { + const parent = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, data.parentId), + }); + if (parent) { + path = `${parent.path}${parent.name}/`; + depth = parent.depth + 1; + } + } + + const [newFile] = await db + .insert(fileItems) + .values({ + ...data, + path, + depth, + createdBy: context.userId, + updatedBy: context.userId, + }) + .returning(); + + await this.logActivity(newFile.id, newFile.projectId, "upload", context); + + return newFile; + } + + // 파일 다운로드 + async downloadFile( + fileId: string, + context: FileAccessContext + ): Promise<FileItem | null> { + const hasAccess = await this.checkFileAccess(fileId, context, "download"); + + if (!hasAccess) { + throw new Error("다운로드 권한이 없습니다"); + } + + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, fileId), + }); + + if (!file) return null; + + // 다운로드 카운트 증가 + await db + .update(fileItems) + .set({ + downloadCount: sql`${fileItems.downloadCount} + 1`, + }) + .where(eq(fileItems.id, fileId)); + + // 활동 로그 기록 + await this.logActivity(fileId, file.projectId, "download", context); + + return file; + } + + // 파일 공유 링크 생성 + async createShareLink( + fileId: string, + options: { + accessLevel?: "view_only" | "view_download"; + password?: string; + expiresAt?: Date; + maxDownloads?: number; + sharedWithEmail?: string; + }, + context: FileAccessContext + ): Promise<string> { + const hasAccess = await this.checkFileAccess(fileId, context, "share"); + + if (!hasAccess) { + throw new Error("공유 권한이 없습니다"); + } + + const shareToken = crypto.randomBytes(32).toString("hex"); + + const [share] = await db + .insert(fileShares) + .values({ + fileItemId: fileId, + shareToken, + accessLevel: options.accessLevel || "view_only", + password: options.password, + expiresAt: options.expiresAt, + maxDownloads: options.maxDownloads, + sharedWithEmail: options.sharedWithEmail, + createdBy: context.userId, + }) + .returning(); + + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, fileId), + }); + + if (file) { + await this.logActivity(fileId, file.projectId, "share", context, { + shareId: share.id, + sharedWithEmail: options.sharedWithEmail, + }); + } + + return shareToken; + } + + // 공유 링크로 파일 접근 + async accessFileByShareToken( + shareToken: string, + password?: string + ): Promise<{ file: FileItem; accessLevel: string } | null> { + const share = await db.query.fileShares.findFirst({ + where: eq(fileShares.shareToken, shareToken), + with: { + fileItem: true, + }, + }); + + if (!share || !share.fileItem) return null; + + // 유효성 검사 + if (share.expiresAt && share.expiresAt < new Date()) { + throw new Error("공유 링크가 만료되었습니다"); + } + + if (share.password && share.password !== password) { + throw new Error("비밀번호가 일치하지 않습니다"); + } + + if ( + share.maxDownloads && + share.currentDownloads >= share.maxDownloads + ) { + throw new Error("최대 다운로드 횟수를 초과했습니다"); + } + + // 접근 기록 업데이트 + await db + .update(fileShares) + .set({ + lastAccessedAt: new Date(), + }) + .where(eq(fileShares.id, share.id)); + + // 조회수 증가 + await db + .update(fileItems) + .set({ + viewCount: sql`${fileItems.viewCount} + 1`, + }) + .where(eq(fileItems.id, share.fileItemId)); + + return { + file: share.fileItem, + accessLevel: share.accessLevel, + }; + } + + // 파일 권한 부여 + async grantPermission( + fileId: string, + targetUserId: number | null, + targetDomain: string | null, + permissions: { + canView?: boolean; + canDownload?: boolean; + canEdit?: boolean; + canDelete?: boolean; + canShare?: boolean; + }, + context: FileAccessContext + ): Promise<void> { + // 내부 사용자만 권한 부여 가능 + if (!this.isInternalUser(context.userDomain)) { + throw new Error("권한 부여 권한이 없습니다"); + } + + await db + .insert(filePermissions) + .values({ + fileItemId: fileId, + userId: targetUserId, + userDomain: targetDomain, + ...permissions, + grantedBy: context.userId, + }) + .onConflictDoUpdate({ + target: [filePermissions.fileItemId, filePermissions.userId], + set: { + ...permissions, + updatedAt: new Date(), + }, + }); + } + + // 활동 로그 기록 + private async logActivity( + fileItemId: string, + projectId: string, + action: string, + context: FileAccessContext, + details: any = {} + ): Promise<void> { + await db.insert(fileActivityLogs).values({ + fileItemId, + projectId, + action, + actionDetails: details, + userId: context.userId, + userEmail: context.userEmail, + userDomain: context.userDomain, + ipAddress: context.ipAddress, + userAgent: context.userAgent, + }); + } + + // 파일 이동 + async moveFile( + fileId: string, + newParentId: string | null, + context: FileAccessContext + ): Promise<void> { + const hasAccess = await this.checkFileAccess(fileId, context, "edit"); + + if (!hasAccess) { + throw new Error("이동 권한이 없습니다"); + } + + // 새 경로 계산 + let newPath = "/"; + let newDepth = 0; + + if (newParentId) { + const newParent = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, newParentId), + }); + if (newParent) { + newPath = `${newParent.path}${newParent.name}/`; + newDepth = newParent.depth + 1; + } + } + + await db + .update(fileItems) + .set({ + parentId: newParentId, + path: newPath, + depth: newDepth, + updatedBy: context.userId, + updatedAt: new Date(), + }) + .where(eq(fileItems.id, fileId)); + + // 하위 항목들의 경로도 재귀적으로 업데이트 필요 (생략) + } + + // 파일 삭제 + async deleteFile( + fileId: string, + context: FileAccessContext + ): Promise<void> { + const hasAccess = await this.checkFileAccess(fileId, context, "delete"); + + if (!hasAccess) { + throw new Error("삭제 권한이 없습니다"); + } + + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, fileId), + }); + + if (file) { + await this.logActivity(fileId, file.projectId, "delete", context); + } + + await db.delete(fileItems).where(eq(fileItems.id, fileId)); + } + + // 프로젝트별 스토리지 사용량 계산 + async getProjectStorageUsage(projectId: string): Promise<{ + totalSize: number; + fileCount: number; + folderCount: number; + }> { + const result = 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)); + + return result[0] || { totalSize: 0, fileCount: 0, folderCount: 0 }; + } +}
\ No newline at end of file diff --git a/lib/services/projectService.ts b/lib/services/projectService.ts new file mode 100644 index 00000000..55ddcf0e --- /dev/null +++ b/lib/services/projectService.ts @@ -0,0 +1,471 @@ +// lib/services/projectService.ts +import db from "@/db/db"; +import { + fileSystemProjects, + fileItems, + projectMembers, + fileActivityLogs, + type FileSystemProject, + type NewFileSystemProject, +} from "@/db/schema/fileSystem"; +import { users } from "@/db/schema/users"; +import { eq, and, or, inArray, gte, sql, not } from "drizzle-orm"; + +// 프로젝트 멤버 역할 타입 +export type ProjectRole = "owner" | "admin" | "editor" | "viewer"; + +export class ProjectService { + // 프로젝트 생성 (생성자가 자동으로 owner가 됨) + async createProject( + data: { + name: string; + description?: string; + isPublic?: boolean; + }, + userId: number + ): Promise<FileSystemProject> { + const [project] = await db.transaction(async (tx) => { + // 1. 프로젝트 생성 + const [newProject] = await tx + .insert(fileSystemProjects) + .values({ + ...data, + ownerId: userId, + }) + .returning(); + + // 2. 생성자를 owner로 프로젝트 멤버에 추가 + await tx.insert(projectMembers).values({ + projectId: newProject.id, + userId: userId, + role: "owner", + addedBy: userId, + }); + + return [newProject]; + }); + + return project; + } + + // 프로젝트 Owner 확인 + async isProjectOwner(projectId: string, userId: number): Promise<boolean> { + const project = await db.query.fileSystemProjects.findFirst({ + where: and( + eq(fileSystemProjects.id, projectId), + eq(fileSystemProjects.ownerId, userId) + ), + }); + + return !!project; + } + + // 프로젝트 접근 권한 확인 + async checkProjectAccess( + projectId: string, + userId: number, + requiredRole?: ProjectRole + ): Promise<{ + hasAccess: boolean; + role?: ProjectRole; + isOwner: boolean; + }> { + // 1. Owner 확인 + const project = await db.query.fileSystemProjects.findFirst({ + where: eq(fileSystemProjects.id, projectId), + }); + + if (!project) { + return { hasAccess: false, isOwner: false }; + } + + const isOwner = project.ownerId === userId; + + // Owner는 모든 권한 보유 + if (isOwner) { + return { hasAccess: true, role: "owner", isOwner: true }; + } + + // 2. 프로젝트 멤버 확인 + const member = await db.query.projectMembers.findFirst({ + where: and( + eq(projectMembers.projectId, projectId), + eq(projectMembers.userId, userId) + ), + }); + + if (!member) { + // 공개 프로젝트인 경우 viewer 권한 + if (project.isPublic) { + return { + hasAccess: !requiredRole || requiredRole === "viewer", + role: "viewer", + isOwner: false + }; + } + return { hasAccess: false, isOwner: false }; + } + + // 3. 역할 계층 확인 + const roleHierarchy: Record<ProjectRole, number> = { + owner: 4, + admin: 3, + editor: 2, + viewer: 1, + }; + + const hasRequiredRole = !requiredRole || + roleHierarchy[member.role] >= roleHierarchy[requiredRole]; + + return { + hasAccess: hasRequiredRole, + role: member.role as ProjectRole, + isOwner: false, + }; + } + + // 프로젝트 멤버 추가 (Owner만 가능) + async addProjectMember( + projectId: string, + newMemberId: number, + role: ProjectRole, + addedByUserId: number + ): Promise<void> { + // Owner 권한 확인 + const isOwner = await this.isProjectOwner(projectId, addedByUserId); + + if (!isOwner) { + throw new Error("프로젝트 소유자만 멤버를 추가할 수 있습니다"); + } + + // Owner 역할은 양도를 통해서만 가능 + if (role === "owner") { + throw new Error("Owner 역할은 직접 할당할 수 없습니다. transferOwnership을 사용하세요."); + } + + await db.insert(projectMembers).values({ + projectId, + userId: newMemberId, + role, + addedBy: addedByUserId, + }); + } + + // 프로젝트 소유권 이전 (Owner만 가능) + async transferOwnership( + projectId: string, + currentOwnerId: number, + newOwnerId: number + ): Promise<void> { + await db.transaction(async (tx) => { + // 1. 현재 Owner 확인 + const project = await tx.query.fileSystemProjects.findFirst({ + where: and( + eq(fileSystemProjects.id, projectId), + eq(fileSystemProjects.ownerId, currentOwnerId) + ), + }); + + if (!project) { + throw new Error("프로젝트 소유자만 소유권을 이전할 수 있습니다"); + } + + // 2. 프로젝트 owner 업데이트 + await tx + .update(fileSystemProjects) + .set({ ownerId: newOwnerId }) + .where(eq(fileSystemProjects.id, projectId)); + + // 3. 프로젝트 멤버 역할 업데이트 + // 이전 owner를 admin으로 변경 + await tx + .update(projectMembers) + .set({ role: "admin" }) + .where( + and( + eq(projectMembers.projectId, projectId), + eq(projectMembers.userId, currentOwnerId) + ) + ); + + // 새 owner를 owner 역할로 설정 (없으면 추가) + await tx + .insert(projectMembers) + .values({ + projectId, + userId: newOwnerId, + role: "owner", + addedBy: currentOwnerId, + }) + .onConflictDoUpdate({ + target: [projectMembers.projectId, projectMembers.userId], + set: { role: "owner", updatedAt: new Date() }, + }); + }); + } + + // 프로젝트 삭제 (Owner만 가능) + async deleteProject(projectId: string, userId: number): Promise<void> { + const isOwner = await this.isProjectOwner(projectId, userId); + + if (!isOwner) { + throw new Error("프로젝트 소유자만 프로젝트를 삭제할 수 있습니다"); + } + + // 프로젝트 삭제 (cascade로 관련 파일, 멤버 등도 삭제됨) + await db.delete(fileSystemProjects).where(eq(fileSystemProjects.id, projectId)); + } + + // 프로젝트 설정 변경 (Owner와 Admin만 가능) + async updateProjectSettings( + projectId: string, + userId: number, + settings: { + name?: string; + description?: string; + isPublic?: boolean; + externalAccessEnabled?: boolean; + } + ): Promise<void> { + const access = await this.checkProjectAccess(projectId, userId, "admin"); + + if (!access.hasAccess) { + throw new Error("프로젝트 설정을 변경할 권한이 없습니다"); + } + + await db + .update(fileSystemProjects) + .set({ + ...settings, + updatedAt: new Date(), + }) + .where(eq(fileSystemProjects.id, projectId)); + } + + // 사용자의 프로젝트 목록 조회 + async getUserProjects(userId: number): Promise<{ + owned: FileSystemProject[]; + member: Array<FileSystemProject & { role: ProjectRole }>; + public: FileSystemProject[]; + }> { + // 1. 소유한 프로젝트 + const ownedProjects = await db.query.fileSystemProjects.findMany({ + where: eq(fileSystemProjects.ownerId, userId), + orderBy: (fileSystemProjects, { desc }) => [desc(fileSystemProjects.createdAt)], + }); + + // 2. 멤버로 참여한 프로젝트 + const memberProjects = await db + .select({ + project: fileSystemProjects, + role: projectMembers.role, + }) + .from(projectMembers) + .innerJoin(fileSystemProjects, eq(fileSystemProjects.id, projectMembers.projectId)) + .where( + and( + eq(projectMembers.userId, userId), + // Owner가 아닌 경우만 (중복 방지) - not 사용 + not(eq(fileSystemProjects.ownerId, userId)) + ) + ); + + // 3. 공개 프로젝트 (참여하지 않은) + const memberProjectIds = memberProjects.map(mp => mp.project.id); + const ownedProjectIds = ownedProjects.map(p => p.id); + const allUserProjectIds = [...memberProjectIds, ...ownedProjectIds]; + + let publicProjects; + if (allUserProjectIds.length > 0) { + publicProjects = await db.query.fileSystemProjects.findMany({ + where: and( + eq(fileSystemProjects.isPublic, true), + not(eq(fileSystemProjects.ownerId, userId)), + not(inArray(fileSystemProjects.id, allUserProjectIds)) + ), + orderBy: (fileSystemProjects, { desc }) => [desc(fileSystemProjects.createdAt)], + }); + } else { + // 사용자가 참여한 프로젝트가 없는 경우 + publicProjects = await db.query.fileSystemProjects.findMany({ + where: and( + eq(fileSystemProjects.isPublic, true), + not(eq(fileSystemProjects.ownerId, userId)) + ), + orderBy: (fileSystemProjects, { desc }) => [desc(fileSystemProjects.createdAt)], + }); + } + + return { + owned: ownedProjects, + member: memberProjects.map(mp => ({ + ...mp.project, + role: mp.role as ProjectRole, + })), + public: publicProjects, + }; + } + + // 프로젝트 통계 (Owner용) + async getProjectStats(projectId: string, userId: number) { + const isOwner = await this.isProjectOwner(projectId, userId); + + if (!isOwner) { + throw new Error("프로젝트 통계는 소유자만 볼 수 있습니다"); + } + + // 파일 통계 + const fileStats = await db + .select({ + totalFiles: sql<number>`COUNT(*)`, + totalSize: sql<number>`COALESCE(SUM(size), 0)`, + publicFiles: sql<number>`COUNT(CASE WHEN category = 'public' THEN 1 END)`, + restrictedFiles: sql<number>`COUNT(CASE WHEN category = 'restricted' THEN 1 END)`, + confidentialFiles: sql<number>`COUNT(CASE WHEN category = 'confidential' THEN 1 END)`, + }) + .from(fileItems) + .where(eq(fileItems.projectId, projectId)); + + // 멤버 통계 + const memberStats = await db + .select({ + totalMembers: sql<number>`COUNT(*)`, + admins: sql<number>`COUNT(CASE WHEN role = 'admin' THEN 1 END)`, + editors: sql<number>`COUNT(CASE WHEN role = 'editor' THEN 1 END)`, + viewers: sql<number>`COUNT(CASE WHEN role = 'viewer' THEN 1 END)`, + }) + .from(projectMembers) + .where(eq(projectMembers.projectId, projectId)); + + // 활동 통계 (최근 30일) + const activityStats = await db + .select({ + totalViews: sql<number>`COUNT(CASE WHEN action = 'view' THEN 1 END)`, + totalDownloads: sql<number>`COUNT(CASE WHEN action = 'download' THEN 1 END)`, + totalUploads: sql<number>`COUNT(CASE WHEN action = 'upload' THEN 1 END)`, + uniqueUsers: sql<number>`COUNT(DISTINCT user_id)`, + }) + .from(fileActivityLogs) + .where( + and( + eq(fileActivityLogs.projectId, projectId), + gte(fileActivityLogs.createdAt, new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)) + ) + ); + + return { + files: fileStats[0], + members: memberStats[0], + activity: activityStats[0], + }; + } + + // 개별 프로젝트 정보 조회 + async getProject(projectId: string): Promise<FileSystemProject | null> { + const project = await db.query.fileSystemProjects.findFirst({ + where: eq(fileSystemProjects.id, projectId), + with: { + owner: true, + }, + }); + + return project || null; + } + + // 프로젝트 보관 + async archiveProject(projectId: string, userId: number): Promise<void> { + const isOwner = await this.isProjectOwner(projectId, userId); + + if (!isOwner) { + throw new Error("프로젝트 소유자만 보관할 수 있습니다"); + } + + // 프로젝트를 보관 상태로 변경 + await db + .update(fileSystemProjects) + .set({ + metadata: sql`jsonb_set(metadata, '{archived}', 'true')`, + updatedAt: new Date(), + }) + .where(eq(fileSystemProjects.id, projectId)); + } + + // 멤버 역할 업데이트 + async updateMemberRole( + projectId: string, + memberId: string, + newRole: ProjectRole + ): Promise<void> { + // Owner 역할은 transferOwnership를 통해서만 가능 + if (newRole === 'owner') { + throw new Error("Owner 역할은 소유권 이전을 통해서만 가능합니다"); + } + + await db + .update(projectMembers) + .set({ + role: newRole, + updatedAt: new Date(), + }) + .where( + and( + eq(projectMembers.projectId, projectId), + eq(projectMembers.id, memberId) + ) + ); + } + + // 프로젝트 멤버 제거 + async removeMember(projectId: string, memberId: string): Promise<void> { + // Owner는 제거할 수 없음 + const member = await db.query.projectMembers.findFirst({ + where: and( + eq(projectMembers.projectId, projectId), + eq(projectMembers.id, memberId) + ), + }); + + if (member?.role === 'owner') { + throw new Error("Owner는 제거할 수 없습니다"); + } + + await db + .delete(projectMembers) + .where( + and( + eq(projectMembers.projectId, projectId), + eq(projectMembers.id, memberId) + ) + ); + } + + // 프로젝트 멤버 목록 조회 + async getProjectMembers(projectId: string): Promise<any[]> { + const members = await db + .select({ + id: projectMembers.id, + userId: projectMembers.userId, + role: projectMembers.role, + addedAt: projectMembers.createdAt, + user: { + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + }, + }) + .from(projectMembers) + .innerJoin(users, eq(users.id, projectMembers.userId)) + .where(eq(projectMembers.projectId, projectId)) + .orderBy( + sql`CASE + WHEN ${projectMembers.role} = 'owner' THEN 1 + WHEN ${projectMembers.role} = 'admin' THEN 2 + WHEN ${projectMembers.role} = 'editor' THEN 3 + ELSE 4 + END` + ); + + return members; + } +} diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 4c1861b9..14035562 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -429,7 +429,7 @@ React.useEffect(() => { {/* Document Number Preview */} <div className="mt-3 p-2 bg-white dark:bg-gray-900 border rounded"> <Label className="text-xs text-gray-600 dark:text-gray-400"> - {activeTab === "SHI" ? "Document Number" : "Vendor Document Number"} Preview: + {activeTab === "SHI" ? "Document Number" : "Project Document Number"} Preview: </Label> <div className="font-mono text-sm font-medium text-blue-600 dark:text-blue-400"> {generatePreviewDocNumber()} @@ -525,85 +525,96 @@ React.useEffect(() => { ) } - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col"> - <DialogHeader className="flex-shrink-0"> - <DialogTitle>Add New Document</DialogTitle> - <DialogDescription> - Enter the basic information for the new document. - </DialogDescription> - </DialogHeader> + return ( +<Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col overflow-hidden"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle>Add New Document</DialogTitle> + <DialogDescription> + Enter the basic information for the new document. + </DialogDescription> + </DialogHeader> + + {!shiType && !cpyType ? ( + <div className="flex-1 flex items-center justify-center"> + <Alert className="max-w-md"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription> + Required Document Number Type (SHI, CPY) is not configured. Please configure it first in the Number Types management. + </AlertDescription> + </Alert> + </div> + ) : ( + <> + <Tabs + value={activeTab} + onValueChange={(v) => handleTabChange(v as "SHI" | "CPY")} + className="flex-1 min-h-0 flex flex-col" + > + {/* 고정 영역 */} + <TabsList className="grid w-full grid-cols-2 flex-shrink-0"> + <TabsTrigger value="SHI" disabled={!shiType}> + SHI (Document No.) + {!shiType && <AlertTriangle className="ml-2 h-3 w-3" />} + </TabsTrigger> + <TabsTrigger value="CPY" disabled={!cpyType}> + CPY (Project Document No.) + {!cpyType && <AlertTriangle className="ml-2 h-3 w-3" />} + </TabsTrigger> + </TabsList> + + {/* 스크롤 영역 */} + <div className="flex-1 min-h-0 mt-4 overflow-y-auto pr-2"> + <TabsContent + value="SHI" + className="data-[state=inactive]:hidden" + > + {shiType ? ( + <DocumentForm /> + ) : ( + <Alert> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription> + SHI Document Number Type is not configured. + </AlertDescription> + </Alert> + )} + </TabsContent> - {!shiType && !cpyType ? ( - <div className="flex-1 flex items-center justify-center"> - <Alert className="max-w-md"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - 필수 Document Number Type (SHI, CPY)이 설정되지 않았습니다. - 먼저 Number Types 관리에서 설정해주세요. - </AlertDescription> - </Alert> + <TabsContent + value="CPY" + className="data-[state=inactive]:hidden" + > + {cpyType ? ( + <DocumentForm /> + ) : ( + <Alert> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription> + CPY Document Number Type is not configured. + </AlertDescription> + </Alert> + )} + </TabsContent> </div> - ) : ( - <> - <Tabs value={activeTab} onValueChange={(v) => handleTabChange(v as "SHI" | "CPY")} className="flex-1 flex flex-col"> - <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="SHI" disabled={!shiType}> - SHI (삼성중공업 도서번호) - {!shiType && <AlertTriangle className="ml-2 h-3 w-3" />} - </TabsTrigger> - <TabsTrigger value="CPY" disabled={!cpyType}> - CPY (프로젝트 문서번호) - {!cpyType && <AlertTriangle className="ml-2 h-3 w-3" />} - </TabsTrigger> - </TabsList> - - <div className="flex-1 overflow-y-auto pr-2 mt-4"> - <TabsContent value="SHI" className="mt-0"> - {shiType ? ( - <DocumentForm /> - ) : ( - <Alert> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - SHI Document Number Type이 설정되지 않았습니다. - </AlertDescription> - </Alert> - )} - </TabsContent> - - <TabsContent value="CPY" className="mt-0"> - {cpyType ? ( - <DocumentForm /> - ) : ( - <Alert> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - CPY Document Number Type이 설정되지 않았습니다. - </AlertDescription> - </Alert> - )} - </TabsContent> - </div> - </Tabs> + </Tabs> - <DialogFooter className="flex-shrink-0"> - <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}> - Cancel - </Button> - <Button - onClick={handleSubmit} - disabled={isSubmitting || !isFormValid() || (!shiType && !cpyType)} - > - {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} - Add Document - </Button> - </DialogFooter> - </> - )} - </DialogContent> - </Dialog> + <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4"> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}> + Cancel + </Button> + <Button + onClick={handleSubmit} + disabled={isSubmitting || !isFormValid() || (!shiType && !cpyType)} + > + {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} + Add Document + </Button> + </DialogFooter> + </> + )} + </DialogContent> +</Dialog> ) } // ============================================================================= @@ -736,12 +747,12 @@ export function EditDocumentDialog({ {/* Vendor Document Number (Plant project only) */} {isPlantProject && ( <div className="grid gap-2"> - <Label htmlFor="edit-vendorDocNumber">Vendor Document Number</Label> + <Label htmlFor="edit-vendorDocNumber">Project Document Number</Label> <Input id="edit-vendorDocNumber" value={formData.vendorDocNumber} onChange={(e) => setFormData({ ...formData, vendorDocNumber: e.target.value })} - placeholder="Vendor provided document number" + placeholder="Project provided document number" /> </div> )} diff --git a/middleware.ts b/middleware.ts index 595f87e1..c53c8455 100644 --- a/middleware.ts +++ b/middleware.ts @@ -17,6 +17,7 @@ const publicPaths = [ '/engineering', '/partners', '/privacy', + '/projects', '/partners/repository', '/partners/signup', '/partners/tech-signup', diff --git a/package-lock.json b/package-lock.json index cd13fae3..f6836f3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,6 +106,7 @@ "@types/docusign-esign": "^5.19.8", "@types/formidable": "^3.4.5", "accept-language": "^3.0.20", + "archiver": "^7.0.1", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -6631,6 +6632,18 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accept-language": { "version": "3.0.20", "resolved": "https://registry.npmjs.org/accept-language/-/accept-language-3.0.20.tgz", @@ -6791,78 +6804,124 @@ "license": "ISC" }, "node_modules/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", "license": "MIT", "dependencies": { - "archiver-utils": "^2.1.0", + "archiver-utils": "^5.0.2", "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" }, "engines": { - "node": ">= 10" + "node": ">= 14" } }, "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", "license": "MIT", "dependencies": { - "glob": "^7.1.4", + "glob": "^10.0.0", "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", + "lodash": "^4.17.15", "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" + "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, - "node_modules/archiver-utils/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } }, "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/are-we-there-yet": { @@ -7228,6 +7287,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", + "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", + "license": "Apache-2.0" + }, "node_modules/base64-arraybuffer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.2.tgz", @@ -7361,6 +7426,30 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/blob": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.2.tgz", @@ -7434,9 +7523,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -7454,16 +7543,16 @@ "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", "license": "MIT", "engines": { - "node": "*" + "node": ">=8.0.0" } }, "node_modules/buffer-equal-constant-time": { @@ -7851,18 +7940,35 @@ "integrity": "sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==" }, "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", "license": "MIT", "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" + "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 10" + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/concat-map": { @@ -7987,16 +8093,32 @@ } }, "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", "license": "MIT", "dependencies": { "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" + "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 10" + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/create-require": { @@ -9675,12 +9797,39 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -9701,6 +9850,134 @@ "node": ">=8.3.0" } }, + "node_modules/exceljs/node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/exceljs/node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/exceljs/node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/exceljs/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/exceljs/node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/exceljs/node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/exceljs/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/exceljs/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/exceljs/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/exceljs/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/exceljs/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -9710,6 +9987,41 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/exceljs/node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/exceljs/node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/express": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/express/-/express-4.6.1.tgz", @@ -9853,6 +10165,12 @@ "node": ">=6.0.0" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -11363,6 +11681,18 @@ ], "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -14197,6 +14527,15 @@ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", "license": "MIT" }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -15833,6 +16172,17 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -16323,19 +16673,28 @@ } }, "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "license": "MIT", "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar-stream/node_modules/b4a": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.2.tgz", + "integrity": "sha512-DyUOdz+E8R6+sruDpQNOaV0y/dBbV6X/8ZkxrDcR0Ifc3BgKlpgG0VAtfOozA0eMtJO5GGe9FsZhueLs00pTww==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" }, - "engines": { - "node": ">=6" + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } } }, "node_modules/tarn": { @@ -16347,6 +16706,29 @@ "node": ">=8.0.0" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.2.tgz", + "integrity": "sha512-DyUOdz+E8R6+sruDpQNOaV0y/dBbV6X/8ZkxrDcR0Ifc3BgKlpgG0VAtfOozA0eMtJO5GGe9FsZhueLs00pTww==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -18078,38 +18460,33 @@ } }, "node_modules/zip-stream": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", "license": "MIT", "dependencies": { - "archiver-utils": "^3.0.4", - "compress-commons": "^4.1.2", - "readable-stream": "^3.6.0" + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 10" + "node": ">= 14" } }, - "node_modules/zip-stream/node_modules/archiver-utils": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", "dependencies": { - "glob": "^7.2.3", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": ">= 10" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/zod": { diff --git a/package.json b/package.json index 6c3aec93..391c89bd 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "@types/docusign-esign": "^5.19.8", "@types/formidable": "^3.4.5", "accept-language": "^3.0.20", + "archiver": "^7.0.1", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", |
