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