summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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
-rw-r--r--components/file-manager/FileManager.tsx1447
-rw-r--r--components/file-manager/SharedFileViewer.tsx411
-rw-r--r--components/project/ProjectDashboard.tsx476
-rw-r--r--components/project/ProjectHeader.tsx84
-rw-r--r--components/project/ProjectList.tsx463
-rw-r--r--components/project/ProjectNav.tsx149
-rw-r--r--components/project/ProjectSidebar.tsx318
-rw-r--r--db/schema/fileSystem.ts377
-rw-r--r--db/schema/index.ts2
-rw-r--r--lib/gtc-contract/service.ts1
-rw-r--r--lib/itb/service.ts417
-rw-r--r--lib/rfq-last/service.ts6
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx2
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx36
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx2
-rw-r--r--lib/rfq-last/vendor/vendor-detail-dialog.tsx10
-rw-r--r--lib/services/fileService.ts516
-rw-r--r--lib/services/projectService.ts471
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx169
-rw-r--r--middleware.ts1
-rw-r--r--package-lock.json573
-rw-r--r--package.json1
54 files changed, 10996 insertions, 407 deletions
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx
new file mode 100644
index 00000000..985e7fef
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx
@@ -0,0 +1,14 @@
+// app/projects/[projectId]/files/page.tsx
+import { FileManager } from '@/components/file-manager/FileManager';
+
+export default function ProjectFilesPage({
+ params,
+}: {
+ params: { projectId: string };
+}) {
+ return (
+ <div className="h-full flex flex-col">
+ <FileManager projectId={params.projectId} />
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx
new file mode 100644
index 00000000..d2e74f8e
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx
@@ -0,0 +1,19 @@
+// app/projects/[projectId]/layout.tsx
+import { ProjectNav } from '@/components/project/ProjectNav';
+
+export default function ProjectLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: { projectId: string };
+}) {
+ return (
+ <div className="flex flex-col h-full">
+ <ProjectNav projectId={params.projectId} />
+ <div className="flex-1 overflow-y-auto">
+ {children}
+ </div>
+ </div>
+ );
+}
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx
new file mode 100644
index 00000000..18442c0e
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx
@@ -0,0 +1,811 @@
+// app/projects/[projectId]/members/page.tsx
+'use client';
+
+import { use, useState, useEffect, useRef } from 'react';
+import {
+ Users,
+ UserPlus,
+ Crown,
+ Shield,
+ Eye,
+ Edit2,
+ Trash2,
+ Mail,
+ MoreVertical,
+ Search,
+ Filter,
+ Check,
+ ChevronsUpDown,
+ Loader2,
+ UserCog
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Badge } from '@/components/ui/badge';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+import {
+ Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow
+} from '@/components/ui/table';
+import { Separator } from '@/components/ui/separator';
+import { getUsersForFilter } from '@/lib/gtc-contract/service';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+
+interface Member {
+ id: string;
+ userId: number;
+ user: {
+ name: string;
+ email: string;
+ imageUrl?: string;
+ domain: string;
+ };
+ role: 'owner' | 'admin' | 'editor' | 'viewer';
+ addedAt: string;
+ lastAccess?: string;
+}
+
+interface User {
+ id: number;
+ name: string;
+ email: string;
+ domain?: string; // 'partners' | 'internal' 등
+}
+
+export default function ProjectMembersPage({
+ params: promiseParams
+}: {
+ params: Promise<{ projectId: string }>
+}) {
+ // Next.js 15+ params Promise 처리
+ const params = use(promiseParams);
+ const projectId = params.projectId;
+
+ const [members, setMembers] = useState<Member[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [roleFilter, setRoleFilter] = useState<string>('all');
+ const [addMemberOpen, setAddMemberOpen] = useState(false);
+ const [editingMember, setEditingMember] = useState<Member | null>(null);
+
+ // 사용자 선택 관련 상태
+ const [availableUsers, setAvailableUsers] = useState<User[]>([]);
+ const [selectedUser, setSelectedUser] = useState<User | null>(null);
+ const [userSearchTerm, setUserSearchTerm] = useState('');
+ const [userPopoverOpen, setUserPopoverOpen] = useState(false);
+ const [loadingUsers, setLoadingUsers] = useState(false);
+ const [isExternalUser, setIsExternalUser] = useState(false); // 외부 사용자 여부
+
+ const [newMemberRole, setNewMemberRole] = useState<string>('viewer');
+ const [currentUserRole, setCurrentUserRole] = useState<string>('viewer');
+ const [page, setPage] = useState(1);
+ const pageSize = 20;
+
+ // Command component key management
+ const userOptionIdsRef = useRef<Record<number, string>>({});
+ const popoverContentId = `popover-content-${Date.now()}`;
+ const commandId = `command-${Date.now()}`;
+
+ const { toast } = useToast();
+
+ useEffect(() => {
+ setPage(1);
+ }, [searchQuery, roleFilter]);
+
+ useEffect(() => {
+ fetchMembers();
+ checkUserRole();
+ }, [projectId]);
+
+ // 다이얼로그가 열릴 때 사용자 목록 가져오기
+ useEffect(() => {
+ if (addMemberOpen) {
+ fetchAvailableUsers();
+ } else {
+ // 다이얼로그가 닫힐 때 초기화
+ setSelectedUser(null);
+ setUserSearchTerm('');
+ setNewMemberRole('viewer');
+ setIsExternalUser(false);
+ }
+ }, [addMemberOpen]);
+
+ const fetchAvailableUsers = async () => {
+ try {
+ setLoadingUsers(true);
+ const users = await getUsersForFilter();
+ // 이미 프로젝트에 있는 멤버는 제외
+ const memberUserIds = members.map(m => m.userId);
+ const filteredUsers = users.filter(u => !memberUserIds.includes(u.id));
+ setAvailableUsers(filteredUsers);
+ } catch (error) {
+ console.error('사용자 목록 로드 실패:', error);
+ toast({
+ title: '오류',
+ description: '사용자 목록을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoadingUsers(false);
+ }
+ };
+
+ const fetchMembers = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(`/api/projects/${projectId}/members`);
+ const data = await response.json();
+ setMembers(data.member);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 목록을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const checkUserRole = async () => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/access`);
+ const data = await response.json();
+ setCurrentUserRole(data.role);
+ } catch (error) {
+ console.error('권한 확인 실패:', error);
+ }
+ };
+
+ const addMember = async () => {
+ if (!selectedUser) {
+ toast({
+ title: '오류',
+ description: '사용자를 선택해주세요.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId: selectedUser.id,
+ role: newMemberRole,
+ }),
+ });
+
+ if (!response.ok) throw new Error('멤버 추가 실패');
+
+ toast({
+ title: '성공',
+ description: '새 멤버가 추가되었습니다.',
+ });
+
+ setAddMemberOpen(false);
+ fetchMembers();
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 추가에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const updateMemberRole = async (memberId: string, newRole: string) => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ role: newRole }),
+ });
+
+ if (!response.ok) throw new Error('역할 변경 실패');
+
+ toast({
+ title: '성공',
+ description: '멤버 역할이 변경되었습니다.',
+ });
+
+ fetchMembers();
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '역할 변경에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const removeMember = async (memberId: string) => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) throw new Error('멤버 제거 실패');
+
+ toast({
+ title: '성공',
+ description: '멤버가 제거되었습니다.',
+ });
+
+ fetchMembers();
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 제거에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const handleSelectUser = (user: User) => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+
+ // 외부 사용자(partners)인 경우 역할을 viewer로 고정
+ if (user.domain === 'partners') {
+ setIsExternalUser(true);
+ setNewMemberRole('viewer');
+ } else {
+ setIsExternalUser(false);
+ // 내부 사용자는 기본값 viewer로 설정하되 변경 가능
+ setNewMemberRole('viewer');
+ }
+ };
+
+ const formatDateShort = (iso?: string) =>
+ iso ? new Date(iso).toLocaleDateString() : '-';
+
+ const roleConfig = {
+ owner: { label: 'Owner', icon: Crown, color: 'text-yellow-500', bg: 'bg-yellow-50' },
+ admin: { label: 'Admin', icon: Shield, color: 'text-blue-500', bg: 'bg-blue-50' },
+ editor: { label: 'Editor', icon: Edit2, color: 'text-green-500', bg: 'bg-green-50' },
+ viewer: { label: 'Viewer', icon: Eye, color: 'text-gray-500', bg: 'bg-gray-50' },
+ };
+
+ const filteredMembers = members.filter(member => {
+ const matchesSearch = member.user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ member.user.email.toLowerCase().includes(searchQuery.toLowerCase());
+ const matchesRole = roleFilter === 'all' || member.role === roleFilter;
+ return matchesSearch && matchesRole;
+ });
+
+ // 사용자 검색 필터링
+ const filteredUsers = availableUsers.filter(user =>
+ user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) ||
+ user.email.toLowerCase().includes(userSearchTerm.toLowerCase())
+ );
+
+ const canManageMembers = currentUserRole === 'owner' || currentUserRole === 'admin';
+
+ const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
+ const paginatedMembers = filteredMembers.slice((page - 1) * pageSize, page * pageSize);
+
+ if (loading) {
+ return (
+ <div className="flex items-center justify-center min-h-[400px]">
+ <div className="text-center space-y-3">
+ <Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
+ <p className="text-sm text-muted-foreground">멤버 목록을 불러오는 중...</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold">프로젝트 멤버</h1>
+ <p className="text-muted-foreground mt-1">
+ 프로젝트에 참여 중인 멤버를 관리합니다
+ </p>
+ </div>
+
+ {canManageMembers && (
+ <Button onClick={() => setAddMemberOpen(true)}>
+ <UserPlus className="h-4 w-4 mr-2" />
+ 멤버 추가
+ </Button>
+ )}
+ </div>
+
+ {/* 필터 */}
+ <div className="flex items-center gap-3">
+ <div className="relative flex-1 max-w-md">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="이름 또는 이메일로 검색..."
+ className="pl-9"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+
+ <Select value={roleFilter} onValueChange={setRoleFilter}>
+ <SelectTrigger className="w-40">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">모든 역할</SelectItem>
+ <SelectItem value="owner">Owner</SelectItem>
+ <SelectItem value="admin">Admin</SelectItem>
+ <SelectItem value="editor">Editor</SelectItem>
+ <SelectItem value="viewer">Viewer</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 멤버 목록 (Table) */}
+ <div className="overflow-x-auto">
+ <Table className="[&_td]:py-2 [&_th]:py-2 text-sm">
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead className="w-[44px]"></TableHead>
+ <TableHead className="w-[100px]">이름</TableHead>
+ <TableHead className="min-w-[150px]">이메일</TableHead>
+ <TableHead className="w-[90px] text-center">구분</TableHead>
+ <TableHead className="w-[140px]">역할</TableHead>
+ <TableHead className="w-[130px]">추가일</TableHead>
+ <TableHead className="w-[150px]">마지막 접속</TableHead>
+ <TableHead className="w-[60px] text-right">액션</TableHead>
+ </TableRow>
+ </TableHeader>
+
+ <TableBody>
+ {paginatedMembers.length > 0 ? (
+ paginatedMembers.map((member) => {
+ const config = roleConfig[member.role];
+ const Icon = config.icon;
+ const isInternal = member.user.domain !== 'partners';
+
+ return (
+ <TableRow key={member.id} className="hover:bg-accent/40">
+ {/* Avatar */}
+ <TableCell className="align-middle">
+ <Avatar className="h-8 w-8">
+ <AvatarImage src={member.user.imageUrl} />
+ <AvatarFallback>
+ {member.user.name?.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ </TableCell>
+
+ {/* Name */}
+ <TableCell className="align-middle">
+ <span className="font-medium">{member.user.name}</span>
+ </TableCell>
+
+ {/* Email */}
+ <TableCell className="align-middle">
+ <span className="text-muted-foreground">{member.user.email}</span>
+ </TableCell>
+
+ {/* Domain */}
+ <TableCell className="align-middle text-center">
+ <Badge variant={isInternal ? 'secondary' : 'outline'}>
+ {isInternal ? 'Internal' : 'Partner'}
+ </Badge>
+ </TableCell>
+
+ {/* Role */}
+ <TableCell className="align-middle">
+ {canManageMembers && member.role !== 'owner' && member.user.domain !== 'partners' ? (
+ <Select
+ value={member.role}
+ onValueChange={(v) => updateMemberRole(member.id, v)}
+ >
+ <SelectTrigger className="h-8 w-[120px]">
+ <div className={cn('flex items-center gap-1')}>
+ <Icon className={cn('h-3 w-3', config.color)} />
+ <span className={cn('text-xs font-medium')}>
+ {config.label}
+ </span>
+ </div>
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer</SelectItem>
+ <SelectItem value="editor">Editor</SelectItem>
+ <SelectItem value="admin">Admin</SelectItem>
+ </SelectContent>
+ </Select>
+ ) : (
+ <div className="inline-flex items-center gap-2">
+ <div className={cn('px-2 py-1 rounded-full inline-flex items-center gap-1', config.bg)}>
+ <Icon className={cn('h-3 w-3', config.color)} />
+ <span className={cn('text-xs font-medium', config.color)}>
+ {config.label}
+ </span>
+ </div>
+ {member.user.domain === 'partners' && canManageMembers && member.role !== 'owner' && (
+ <span className="text-xs text-muted-foreground">(고정)</span>
+ )}
+ </div>
+ )}
+ </TableCell>
+
+ {/* AddedAt */}
+ <TableCell className="align-middle">
+ {formatDateShort(member.addedAt)}
+ </TableCell>
+
+ {/* LastAccess */}
+ <TableCell className="align-middle">
+ {formatDateShort(member.lastAccess)}
+ </TableCell>
+
+ {/* Actions */}
+ <TableCell className="align-middle">
+ <div className="flex justify-end">
+ {canManageMembers && member.role !== 'owner' ? (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem>
+ <Mail className="h-4 w-4 mr-2" />
+ 메일 보내기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ className="text-red-600"
+ onClick={() => removeMember(member.id)}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ 제거
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ) : (
+ <Button variant="ghost" size="icon" disabled>
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </TableCell>
+ </TableRow>
+ );
+ })
+ ) : (
+ <TableRow>
+ <TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
+ <div className="flex flex-col items-center justify-center gap-2">
+ <Users className="h-8 w-8 text-muted-foreground/60" />
+ <span>검색 결과가 없습니다</span>
+ </div>
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* Pagination */}
+ <div className="flex items-center justify-between px-4 py-3 border-t">
+ <div className="text-sm text-muted-foreground">
+ 총 {filteredMembers.length}명 · {pageSize}명/페이지
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >
+ 이전
+ </Button>
+ <span className="text-sm">
+ {page} / {totalPages}
+ </span>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page === totalPages}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+
+ {/* 멤버 추가 다이얼로그 */}
+ <Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
+ <DialogContent className="max-w-lg">
+ <DialogHeader>
+ <DialogTitle>멤버 추가</DialogTitle>
+ <DialogDescription>
+ 프로젝트에 멤버를 추가합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="internal" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="internal">내부 사용자</TabsTrigger>
+ <TabsTrigger value="external" className="flex items-center gap-2">
+ 외부 사용자
+ <Badge variant="outline" className="ml-1 text-xs">Viewer 전용</Badge>
+ </TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="internal" className="space-y-4 mt-4">
+ <div className="space-y-2">
+ <Label htmlFor="internal-user">사용자 선택</Label>
+
+ {loadingUsers ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span>
+ </div>
+ ) : (
+ <>
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={userPopoverOpen}
+ className="w-full justify-between"
+ >
+ <span className="truncate">
+ {selectedUser && selectedUser.domain !== 'partners' ? (
+ <div className="text-left">
+ <div className="font-medium">{selectedUser.name}</div>
+ <div className="text-xs text-muted-foreground">{selectedUser.email}</div>
+ </div>
+ ) : (
+ "내부 사용자를 선택하세요..."
+ )}
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[460px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="이름 또는 이메일로 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList
+ className="max-h-[300px]"
+ onWheel={(e) => {
+ e.stopPropagation();
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY;
+ }}
+ >
+ <CommandEmpty>사용자를 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup heading="내부 사용자 목록">
+ {filteredUsers
+ .filter(u => u.domain !== 'partners')
+ .map((user) => (
+ <CommandItem
+ key={user.id}
+ onSelect={() => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+ setIsExternalUser(false);
+ setNewMemberRole('viewer');
+ }}
+ value={`${user.name} ${user.email}`}
+ className="truncate"
+ >
+ <Users className="mr-2 h-4 w-4 text-blue-500 flex-shrink-0" />
+ <div className="flex-1 truncate">
+ <div className="font-medium truncate">{user.name}</div>
+ <div className="text-xs text-muted-foreground truncate">{user.email}</div>
+ </div>
+ <Check
+ className={cn(
+ "ml-2 h-4 w-4 flex-shrink-0",
+ selectedUser?.id === user.id && !isExternalUser ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ <p className="text-xs text-muted-foreground">
+ 내부 사용자는 모든 역할을 부여할 수 있습니다.
+ </p>
+ </>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="internal-role">역할</Label>
+ <Select
+ value={newMemberRole}
+ onValueChange={setNewMemberRole}
+ disabled={!selectedUser || isExternalUser}
+ >
+ <SelectTrigger id="internal-role">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem>
+ <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem>
+ <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </TabsContent>
+
+ <TabsContent value="external" className="space-y-4 mt-4">
+ <div className="rounded-lg bg-amber-50 border border-amber-200 p-3 mb-4">
+ <p className="text-sm text-amber-800">
+ <strong>보안 정책 안내</strong><br/>
+ 외부 사용자(파트너)는 보안 정책상 Viewer 권한만 부여 가능합니다.
+ </p>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="external-user">파트너 선택</Label>
+
+ {loadingUsers ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span>
+ </div>
+ ) : (
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={userPopoverOpen}
+ className="w-full justify-between"
+ >
+ <span className="truncate">
+ {selectedUser && selectedUser.domain === 'partners' ? (
+ <span className="flex items-center gap-2">
+ {selectedUser.name}
+ <Badge variant="outline" className="ml-1 text-xs">외부</Badge>
+ </span>
+ ) : (
+ "외부 사용자를 선택하세요..."
+ )}
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[460px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="이름으로 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList
+ className="max-h-[300px]"
+ onWheel={(e) => {
+ e.stopPropagation();
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY;
+ }}
+ >
+ <CommandEmpty>파트너를 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup heading="파트너 목록">
+ {filteredUsers
+ .filter(u => u.domain === 'partners')
+ .map((user) => (
+ <CommandItem
+ key={user.id}
+ onSelect={() => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+ setIsExternalUser(true);
+ setNewMemberRole('viewer');
+ }}
+ value={user.name}
+ className="truncate"
+ >
+ <Users className="mr-2 h-4 w-4 text-amber-600" />
+ <span className="truncate flex-1">{user.name}</span>
+ <Badge variant="outline" className="text-xs mx-2">파트너</Badge>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ selectedUser?.id === user.id && isExternalUser ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="external-role">역할</Label>
+ <Select value="viewer" disabled>
+ <SelectTrigger id="external-role" className="opacity-60">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer - 읽기 전용 (고정)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </TabsContent>
+ </Tabs>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setAddMemberOpen(false);
+ setSelectedUser(null);
+ setUserSearchTerm('');
+ setNewMemberRole('viewer');
+ setIsExternalUser(false);
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={addMember}
+ disabled={!selectedUser}
+ >
+ 추가하기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx
new file mode 100644
index 00000000..d54a8cab
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx
@@ -0,0 +1,10 @@
+// app/projects/[projectId]/page.tsx
+import { ProjectDashboard } from '@/components/project/ProjectDashboard';
+
+export default function ProjectPage({
+ params,
+}: {
+ params: { projectId: string };
+}) {
+ return <ProjectDashboard projectId={params.projectId} />;
+}
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx
new file mode 100644
index 00000000..aa0f3b52
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx
@@ -0,0 +1,488 @@
+
+// app/projects/[projectId]/settings/page.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+ Settings,
+ Shield,
+ Globe,
+ Trash2,
+ AlertCircle,
+ Save,
+ Lock,
+ Unlock,
+ Archive,
+ Users,
+ HardDrive
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { Textarea } from '@/components/ui/textarea';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { useToast } from '@/hooks/use-toast';
+import { useRouter } from 'next/navigation';
+
+interface ProjectSettings {
+ id: string;
+ name: string;
+ description: string;
+ isPublic: boolean;
+ externalAccessEnabled: boolean;
+ storageLimit: number;
+ maxFileSize: number;
+ allowedFileTypes: string[];
+ autoArchiveDays: number;
+ requireApproval: boolean;
+ defaultCategory: string;
+}
+
+export default function ProjectSettingsPage({
+ params
+}: {
+ params: { projectId: string }
+}) {
+ const [settings, setSettings] = useState<ProjectSettings | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
+ const [currentUserRole, setCurrentUserRole] = useState<string>('viewer');
+
+ const { toast } = useToast();
+ const router = useRouter();
+
+ useEffect(() => {
+ fetchSettings();
+ checkUserRole();
+ }, [params.projectId]);
+
+ const fetchSettings = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(`/api/projects/${params.projectId}/settings`);
+
+ if (!response.ok) {
+ throw new Error('설정을 불러올 수 없습니다');
+ }
+
+ const data = await response.json();
+ setSettings(data);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 설정을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const checkUserRole = async () => {
+ try {
+ const response = await fetch(`/api/projects/${params.projectId}/access`);
+ const data = await response.json();
+ setCurrentUserRole(data.role);
+ } catch (error) {
+ console.error('권한 확인 실패:', error);
+ }
+ };
+
+ const saveSettings = async () => {
+ if (!settings) return;
+
+ try {
+ setSaving(true);
+ const response = await fetch(`/api/projects/${params.projectId}/settings`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(settings),
+ });
+
+ if (!response.ok) throw new Error('설정 저장 실패');
+
+ toast({
+ title: '성공',
+ description: '프로젝트 설정이 저장되었습니다.',
+ });
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '설정 저장에 실패했습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const deleteProject = async () => {
+ try {
+ const response = await fetch(`/api/projects/${params.projectId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) throw new Error('프로젝트 삭제 실패');
+
+ toast({
+ title: '성공',
+ description: '프로젝트가 삭제되었습니다.',
+ });
+
+ router.push('/projects');
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 삭제에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const archiveProject = async () => {
+ try {
+ const response = await fetch(`/api/projects/${params.projectId}/archive`, {
+ method: 'POST',
+ });
+
+ if (!response.ok) throw new Error('프로젝트 보관 실패');
+
+ toast({
+ title: '성공',
+ description: '프로젝트가 보관되었습니다.',
+ });
+
+ router.push('/projects');
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 보관에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const canEdit = currentUserRole === 'owner' || currentUserRole === 'admin';
+
+ if (loading || !settings) {
+ return (
+ <div className="p-6">
+ <div className="animate-pulse space-y-4">
+ {[...Array(5)].map((_, i) => (
+ <div key={i} className="h-20 bg-gray-200 rounded" />
+ ))}
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="p-6 space-y-6 max-w-4xl">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold">프로젝트 설정</h1>
+ <p className="text-muted-foreground mt-1">
+ 프로젝트 설정을 관리합니다
+ </p>
+ </div>
+
+ {canEdit && (
+ <Button onClick={saveSettings} disabled={saving}>
+ <Save className="h-4 w-4 mr-2" />
+ {saving ? '저장 중...' : '변경사항 저장'}
+ </Button>
+ )}
+ </div>
+
+ {!canEdit && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 프로젝트 설정을 변경하려면 Owner 또는 Admin 권한이 필요합니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ <Tabs defaultValue="general">
+ <TabsList>
+ <TabsTrigger value="general">일반</TabsTrigger>
+ <TabsTrigger value="access">접근 관리</TabsTrigger>
+ <TabsTrigger value="storage">스토리지</TabsTrigger>
+ {currentUserRole === 'owner' && (
+ <TabsTrigger value="danger">위험 영역</TabsTrigger>
+ )}
+ </TabsList>
+
+ <TabsContent value="general" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <Label htmlFor="name">프로젝트 이름</Label>
+ <Input
+ id="name"
+ value={settings.name}
+ onChange={(e) => setSettings({ ...settings, name: e.target.value })}
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="description">설명</Label>
+ <Textarea
+ id="description"
+ value={settings.description}
+ onChange={(e) => setSettings({ ...settings, description: e.target.value })}
+ disabled={!canEdit}
+ rows={3}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="category">기본 파일 카테고리</Label>
+ <Select
+ value={settings.defaultCategory}
+ onValueChange={(value) => setSettings({ ...settings, defaultCategory: value })}
+ disabled={!canEdit}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="public">Public - 공개</SelectItem>
+ <SelectItem value="restricted">Restricted - 제한</SelectItem>
+ <SelectItem value="confidential">Confidential - 기밀</SelectItem>
+ <SelectItem value="internal">Internal - 내부</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="access" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>접근 설정</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label htmlFor="public">공개 프로젝트</Label>
+ <p className="text-sm text-muted-foreground">
+ 모든 사용자가 이 프로젝트를 볼 수 있습니다
+ </p>
+ </div>
+ <Switch
+ id="public"
+ checked={settings.isPublic}
+ onCheckedChange={(checked) => setSettings({ ...settings, isPublic: checked })}
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label htmlFor="external">외부 사용자 접근 허용</Label>
+ <p className="text-sm text-muted-foreground">
+ 파트너사 사용자도 접근할 수 있습니다
+ </p>
+ </div>
+ <Switch
+ id="external"
+ checked={settings.externalAccessEnabled}
+ onCheckedChange={(checked) =>
+ setSettings({ ...settings, externalAccessEnabled: checked })
+ }
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label htmlFor="approval">멤버 승인 필요</Label>
+ <p className="text-sm text-muted-foreground">
+ 새 멤버 참여 시 관리자 승인이 필요합니다
+ </p>
+ </div>
+ <Switch
+ id="approval"
+ checked={settings.requireApproval}
+ onCheckedChange={(checked) =>
+ setSettings({ ...settings, requireApproval: checked })
+ }
+ disabled={!canEdit}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="storage" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>스토리지 설정</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <Label htmlFor="storage-limit">스토리지 제한 (GB)</Label>
+ <Input
+ id="storage-limit"
+ type="number"
+ value={settings.storageLimit}
+ onChange={(e) => setSettings({
+ ...settings,
+ storageLimit: parseInt(e.target.value)
+ })}
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="file-size">최대 파일 크기 (MB)</Label>
+ <Input
+ id="file-size"
+ type="number"
+ value={settings.maxFileSize}
+ onChange={(e) => setSettings({
+ ...settings,
+ maxFileSize: parseInt(e.target.value)
+ })}
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="auto-archive">자동 보관 (일)</Label>
+ <Input
+ id="auto-archive"
+ type="number"
+ value={settings.autoArchiveDays}
+ onChange={(e) => setSettings({
+ ...settings,
+ autoArchiveDays: parseInt(e.target.value)
+ })}
+ disabled={!canEdit}
+ />
+ <p className="text-sm text-muted-foreground mt-1">
+ 설정한 기간 동안 접근하지 않은 파일을 자동으로 보관합니다
+ </p>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {currentUserRole === 'owner' && (
+ <TabsContent value="danger" className="space-y-4">
+ <Card className="border-red-200">
+ <CardHeader>
+ <CardTitle className="text-red-600">위험 영역</CardTitle>
+ <CardDescription>
+ 이 작업들은 되돌릴 수 없습니다. 신중하게 진행하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-between p-4 border rounded-lg">
+ <div>
+ <h3 className="font-medium">프로젝트 보관</h3>
+ <p className="text-sm text-muted-foreground">
+ 프로젝트를 보관하면 읽기 전용이 됩니다
+ </p>
+ </div>
+ <Button
+ variant="outline"
+ onClick={() => setArchiveDialogOpen(true)}
+ >
+ <Archive className="h-4 w-4 mr-2" />
+ 프로젝트 보관
+ </Button>
+ </div>
+
+ <div className="flex items-center justify-between p-4 border rounded-lg border-red-200">
+ <div>
+ <h3 className="font-medium text-red-600">프로젝트 삭제</h3>
+ <p className="text-sm text-muted-foreground">
+ 프로젝트와 모든 파일을 영구적으로 삭제합니다
+ </p>
+ </div>
+ <Button
+ variant="destructive"
+ onClick={() => setDeleteDialogOpen(true)}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ 프로젝트 삭제
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ )}
+ </Tabs>
+
+ {/* 삭제 확인 다이얼로그 */}
+ <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>프로젝트 삭제</DialogTitle>
+ <DialogDescription className="text-red-600">
+ 정말로 이 프로젝트를 삭제하시겠습니까?
+ 모든 파일과 데이터가 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
+ 취소
+ </Button>
+ <Button variant="destructive" onClick={deleteProject}>
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 보관 확인 다이얼로그 */}
+ <Dialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>프로젝트 보관</DialogTitle>
+ <DialogDescription>
+ 프로젝트를 보관하시겠습니까?
+ 보관된 프로젝트는 읽기 전용이 되며, 언제든지 복원할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setArchiveDialogOpen(false)}>
+ 취소
+ </Button>
+ <Button onClick={archiveProject}>
+ 보관
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx
new file mode 100644
index 00000000..7f652a99
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx
@@ -0,0 +1,373 @@
+// app/projects/[projectId]/stats/page.tsx
+'use client';
+
+import { use, useState, useEffect } from 'react';
+import {
+ BarChart3,
+ TrendingUp,
+ HardDrive,
+ Users,
+ Eye,
+ Download,
+ Upload,
+ Calendar,
+ FileText,
+ FolderOpen,
+ Activity
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Badge } from '@/components/ui/badge';
+import { Progress } from '@/components/ui/progress';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+
+interface ProjectStats {
+ storage: {
+ used: number;
+ limit: number;
+ fileCount: number;
+ folderCount: number;
+ byCategory: {
+ public: number;
+ restricted: number;
+ confidential: number;
+ internal: number;
+ };
+ };
+ activity: {
+ views: number;
+ downloads: number;
+ uploads: number;
+ shares: number;
+ trend: number; // 증감률
+ };
+ users: {
+ total: number;
+ active: number;
+ byRole: {
+ admin: number;
+ editor: number;
+ viewer: number;
+ };
+ };
+ recent: {
+ type: string;
+ user: string;
+ action: string;
+ timestamp: string;
+ details: string;
+ }[];
+}
+
+export default function ProjectStatsPage({
+ params
+}: {
+ params: Promise<{ projectId: string }>
+}) {
+ // Next.js 15에서 params를 unwrap
+ const resolvedParams = use(params);
+ const projectId = resolvedParams.projectId;
+
+ const [stats, setStats] = useState<ProjectStats | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [dateRange, setDateRange] = useState('30d');
+ const { toast } = useToast();
+
+ useEffect(() => {
+ fetchStats();
+ }, [projectId, dateRange]);
+
+ const fetchStats = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(
+ `/api/projects/${projectId}/stats?range=${dateRange}`
+ );
+
+ if (!response.ok) {
+ if (response.status === 403) {
+ throw new Error('통계를 볼 권한이 없습니다');
+ }
+ throw new Error('통계 로드 실패');
+ }
+
+ const data = await response.json();
+ setStats(data);
+ } catch (error: any) {
+ toast({
+ title: '오류',
+ description: error.message || '통계를 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const formatBytes = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ const formatNumber = (num: number) => {
+ return new Intl.NumberFormat('ko-KR').format(num);
+ };
+
+ if (loading) {
+ return (
+ <div className="p-6">
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+ {[...Array(8)].map((_, i) => (
+ <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" />
+ ))}
+ </div>
+ </div>
+ );
+ }
+
+ if (!stats) {
+ return (
+ <div className="p-6 text-center">
+ <BarChart3 className="h-12 w-12 mx-auto mb-3 text-muted-foreground" />
+ <p className="text-muted-foreground">통계를 불러올 수 없습니다</p>
+ </div>
+ );
+ }
+
+ const storagePercentage = (stats.storage.used / stats.storage.limit) * 100;
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold">프로젝트 통계</h1>
+ <p className="text-muted-foreground mt-1">
+ 프로젝트 사용 현황과 활동 내역을 확인합니다
+ </p>
+ </div>
+
+ <Tabs value={dateRange} onValueChange={setDateRange}>
+ <TabsList>
+ <TabsTrigger value="7d">7일</TabsTrigger>
+ <TabsTrigger value="30d">30일</TabsTrigger>
+ <TabsTrigger value="90d">90일</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ </div>
+
+ {/* 주요 지표 */}
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">스토리지 사용량</CardTitle>
+ <HardDrive className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {formatBytes(stats.storage.used)}
+ </div>
+ {/* <Progress value={storagePercentage} className="mt-2" /> */}
+ {/* <p className="text-xs text-muted-foreground mt-1">
+ 총 {formatBytes(stats.storage.limit)} 중 {storagePercentage.toFixed(1)}% 사용
+ </p> */}
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">파일 수</CardTitle>
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {formatNumber(stats.storage.fileCount)}
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 폴더 {formatNumber(stats.storage.folderCount)}개 포함
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">활성 사용자</CardTitle>
+ <Users className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {stats.users.active}
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 전체 {stats.users.total}명 중
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">총 다운로드</CardTitle>
+ <Download className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {formatNumber(stats.activity.downloads)}
+ </div>
+ <div className="flex items-center gap-1 mt-1">
+ {stats.activity.trend > 0 ? (
+ <TrendingUp className="h-3 w-3 text-green-500" />
+ ) : (
+ <TrendingUp className="h-3 w-3 text-red-500 rotate-180" />
+ )}
+ <span className={cn(
+ "text-xs",
+ stats.activity.trend > 0 ? "text-green-500" : "text-red-500"
+ )}>
+ {Math.abs(stats.activity.trend)}%
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 상세 통계 */}
+ <div className="grid gap-6 md:grid-cols-2">
+ {/* 파일 카테고리 분포 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>파일 카테고리</CardTitle>
+ <CardDescription>카테고리별 파일 분포</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-green-500 rounded-full" />
+ <span className="text-sm">Public</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.public}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-yellow-500 rounded-full" />
+ <span className="text-sm">Restricted</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.restricted}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-red-500 rounded-full" />
+ <span className="text-sm">Confidential</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.confidential}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-blue-500 rounded-full" />
+ <span className="text-sm">Internal</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.internal}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 활동 요약 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>활동 요약</CardTitle>
+ <CardDescription>기간별 활동 내역</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Eye className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">조회수</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.views)}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Download className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">다운로드</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.downloads)}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Upload className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">업로드</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.uploads)}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Users className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">공유</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.shares)}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+{/* 최근 활동 */}
+<Card>
+ <CardHeader>
+ <CardTitle>최근 활동</CardTitle>
+ <CardDescription>프로젝트 내 최근 활동 내역</CardDescription>
+ </CardHeader>
+
+ {/* 패딩이 스크롤에 포함되도록 CardContent p-0 + 내부 래퍼에 패딩 */}
+ <CardContent className="p-0">
+ <div
+ className="max-h-80 md:max-h-96 xl:max-h-[480px] overflow-y-auto px-6 pb-6"
+ style={{ scrollbarGutter: "stable" }} // 스크롤바 생겨도 레이아웃 흔들림 방지
+ aria-label="최근 활동 스크롤 영역"
+ tabIndex={0} // 키보드 포커스 가능
+ >
+ <ul role="list" className="divide-y">
+ {stats.recent.map((activity, index) => (
+ <li key={index} className="flex items-center gap-3 py-3">
+ <Activity className="h-4 w-4 text-muted-foreground shrink-0" />
+ <div className="min-w-0 flex-1">
+ <p className="text-sm">
+ <span className="font-medium">{activity.user}</span>
+ {" "}님이{" "}
+ <span className="font-medium">{activity.details}</span>
+ {activity.action === "upload" && "을(를) 업로드했습니다"}
+ {activity.action === "download" && "을(를) 다운로드했습니다"}
+ {activity.action === "view" && "을(를) 조회했습니다"}
+ {activity.action === "share" && "을(를) 공유했습니다"}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {new Date(activity.timestamp).toLocaleString()}
+ </p>
+ </div>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </CardContent>
+</Card>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/data-room/page.tsx b/app/[lng]/evcp/(evcp)/data-room/page.tsx
new file mode 100644
index 00000000..4ff56abc
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/data-room/page.tsx
@@ -0,0 +1,26 @@
+// app/projects/page.tsx
+import { Suspense } from 'react';
+import { ProjectHeader } from '@/components/project/ProjectHeader';
+import { ProjectList } from '@/components/project/ProjectList';
+
+export default function ProjectsPage() {
+ return (
+ <div className="min-h-screen">
+ <div className="container mx-auto px-4 py-8 max-w-7xl">
+ <Suspense fallback={<ProjectListSkeleton />}>
+ <ProjectList />
+ </Suspense>
+ </div>
+ </div>
+ );
+}
+
+function ProjectListSkeleton() {
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+ {[...Array(6)].map((_, i) => (
+ <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" />
+ ))}
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx
new file mode 100644
index 00000000..985e7fef
--- /dev/null
+++ b/app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx
@@ -0,0 +1,14 @@
+// app/projects/[projectId]/files/page.tsx
+import { FileManager } from '@/components/file-manager/FileManager';
+
+export default function ProjectFilesPage({
+ params,
+}: {
+ params: { projectId: string };
+}) {
+ return (
+ <div className="h-full flex flex-col">
+ <FileManager projectId={params.projectId} />
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx
new file mode 100644
index 00000000..d2e74f8e
--- /dev/null
+++ b/app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx
@@ -0,0 +1,19 @@
+// app/projects/[projectId]/layout.tsx
+import { ProjectNav } from '@/components/project/ProjectNav';
+
+export default function ProjectLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: { projectId: string };
+}) {
+ return (
+ <div className="flex flex-col h-full">
+ <ProjectNav projectId={params.projectId} />
+ <div className="flex-1 overflow-y-auto">
+ {children}
+ </div>
+ </div>
+ );
+}
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx
new file mode 100644
index 00000000..18442c0e
--- /dev/null
+++ b/app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx
@@ -0,0 +1,811 @@
+// app/projects/[projectId]/members/page.tsx
+'use client';
+
+import { use, useState, useEffect, useRef } from 'react';
+import {
+ Users,
+ UserPlus,
+ Crown,
+ Shield,
+ Eye,
+ Edit2,
+ Trash2,
+ Mail,
+ MoreVertical,
+ Search,
+ Filter,
+ Check,
+ ChevronsUpDown,
+ Loader2,
+ UserCog
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Badge } from '@/components/ui/badge';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+import {
+ Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow
+} from '@/components/ui/table';
+import { Separator } from '@/components/ui/separator';
+import { getUsersForFilter } from '@/lib/gtc-contract/service';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+
+interface Member {
+ id: string;
+ userId: number;
+ user: {
+ name: string;
+ email: string;
+ imageUrl?: string;
+ domain: string;
+ };
+ role: 'owner' | 'admin' | 'editor' | 'viewer';
+ addedAt: string;
+ lastAccess?: string;
+}
+
+interface User {
+ id: number;
+ name: string;
+ email: string;
+ domain?: string; // 'partners' | 'internal' 등
+}
+
+export default function ProjectMembersPage({
+ params: promiseParams
+}: {
+ params: Promise<{ projectId: string }>
+}) {
+ // Next.js 15+ params Promise 처리
+ const params = use(promiseParams);
+ const projectId = params.projectId;
+
+ const [members, setMembers] = useState<Member[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [roleFilter, setRoleFilter] = useState<string>('all');
+ const [addMemberOpen, setAddMemberOpen] = useState(false);
+ const [editingMember, setEditingMember] = useState<Member | null>(null);
+
+ // 사용자 선택 관련 상태
+ const [availableUsers, setAvailableUsers] = useState<User[]>([]);
+ const [selectedUser, setSelectedUser] = useState<User | null>(null);
+ const [userSearchTerm, setUserSearchTerm] = useState('');
+ const [userPopoverOpen, setUserPopoverOpen] = useState(false);
+ const [loadingUsers, setLoadingUsers] = useState(false);
+ const [isExternalUser, setIsExternalUser] = useState(false); // 외부 사용자 여부
+
+ const [newMemberRole, setNewMemberRole] = useState<string>('viewer');
+ const [currentUserRole, setCurrentUserRole] = useState<string>('viewer');
+ const [page, setPage] = useState(1);
+ const pageSize = 20;
+
+ // Command component key management
+ const userOptionIdsRef = useRef<Record<number, string>>({});
+ const popoverContentId = `popover-content-${Date.now()}`;
+ const commandId = `command-${Date.now()}`;
+
+ const { toast } = useToast();
+
+ useEffect(() => {
+ setPage(1);
+ }, [searchQuery, roleFilter]);
+
+ useEffect(() => {
+ fetchMembers();
+ checkUserRole();
+ }, [projectId]);
+
+ // 다이얼로그가 열릴 때 사용자 목록 가져오기
+ useEffect(() => {
+ if (addMemberOpen) {
+ fetchAvailableUsers();
+ } else {
+ // 다이얼로그가 닫힐 때 초기화
+ setSelectedUser(null);
+ setUserSearchTerm('');
+ setNewMemberRole('viewer');
+ setIsExternalUser(false);
+ }
+ }, [addMemberOpen]);
+
+ const fetchAvailableUsers = async () => {
+ try {
+ setLoadingUsers(true);
+ const users = await getUsersForFilter();
+ // 이미 프로젝트에 있는 멤버는 제외
+ const memberUserIds = members.map(m => m.userId);
+ const filteredUsers = users.filter(u => !memberUserIds.includes(u.id));
+ setAvailableUsers(filteredUsers);
+ } catch (error) {
+ console.error('사용자 목록 로드 실패:', error);
+ toast({
+ title: '오류',
+ description: '사용자 목록을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoadingUsers(false);
+ }
+ };
+
+ const fetchMembers = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(`/api/projects/${projectId}/members`);
+ const data = await response.json();
+ setMembers(data.member);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 목록을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const checkUserRole = async () => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/access`);
+ const data = await response.json();
+ setCurrentUserRole(data.role);
+ } catch (error) {
+ console.error('권한 확인 실패:', error);
+ }
+ };
+
+ const addMember = async () => {
+ if (!selectedUser) {
+ toast({
+ title: '오류',
+ description: '사용자를 선택해주세요.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId: selectedUser.id,
+ role: newMemberRole,
+ }),
+ });
+
+ if (!response.ok) throw new Error('멤버 추가 실패');
+
+ toast({
+ title: '성공',
+ description: '새 멤버가 추가되었습니다.',
+ });
+
+ setAddMemberOpen(false);
+ fetchMembers();
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 추가에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const updateMemberRole = async (memberId: string, newRole: string) => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ role: newRole }),
+ });
+
+ if (!response.ok) throw new Error('역할 변경 실패');
+
+ toast({
+ title: '성공',
+ description: '멤버 역할이 변경되었습니다.',
+ });
+
+ fetchMembers();
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '역할 변경에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const removeMember = async (memberId: string) => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) throw new Error('멤버 제거 실패');
+
+ toast({
+ title: '성공',
+ description: '멤버가 제거되었습니다.',
+ });
+
+ fetchMembers();
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 제거에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const handleSelectUser = (user: User) => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+
+ // 외부 사용자(partners)인 경우 역할을 viewer로 고정
+ if (user.domain === 'partners') {
+ setIsExternalUser(true);
+ setNewMemberRole('viewer');
+ } else {
+ setIsExternalUser(false);
+ // 내부 사용자는 기본값 viewer로 설정하되 변경 가능
+ setNewMemberRole('viewer');
+ }
+ };
+
+ const formatDateShort = (iso?: string) =>
+ iso ? new Date(iso).toLocaleDateString() : '-';
+
+ const roleConfig = {
+ owner: { label: 'Owner', icon: Crown, color: 'text-yellow-500', bg: 'bg-yellow-50' },
+ admin: { label: 'Admin', icon: Shield, color: 'text-blue-500', bg: 'bg-blue-50' },
+ editor: { label: 'Editor', icon: Edit2, color: 'text-green-500', bg: 'bg-green-50' },
+ viewer: { label: 'Viewer', icon: Eye, color: 'text-gray-500', bg: 'bg-gray-50' },
+ };
+
+ const filteredMembers = members.filter(member => {
+ const matchesSearch = member.user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ member.user.email.toLowerCase().includes(searchQuery.toLowerCase());
+ const matchesRole = roleFilter === 'all' || member.role === roleFilter;
+ return matchesSearch && matchesRole;
+ });
+
+ // 사용자 검색 필터링
+ const filteredUsers = availableUsers.filter(user =>
+ user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) ||
+ user.email.toLowerCase().includes(userSearchTerm.toLowerCase())
+ );
+
+ const canManageMembers = currentUserRole === 'owner' || currentUserRole === 'admin';
+
+ const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
+ const paginatedMembers = filteredMembers.slice((page - 1) * pageSize, page * pageSize);
+
+ if (loading) {
+ return (
+ <div className="flex items-center justify-center min-h-[400px]">
+ <div className="text-center space-y-3">
+ <Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
+ <p className="text-sm text-muted-foreground">멤버 목록을 불러오는 중...</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold">프로젝트 멤버</h1>
+ <p className="text-muted-foreground mt-1">
+ 프로젝트에 참여 중인 멤버를 관리합니다
+ </p>
+ </div>
+
+ {canManageMembers && (
+ <Button onClick={() => setAddMemberOpen(true)}>
+ <UserPlus className="h-4 w-4 mr-2" />
+ 멤버 추가
+ </Button>
+ )}
+ </div>
+
+ {/* 필터 */}
+ <div className="flex items-center gap-3">
+ <div className="relative flex-1 max-w-md">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="이름 또는 이메일로 검색..."
+ className="pl-9"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+
+ <Select value={roleFilter} onValueChange={setRoleFilter}>
+ <SelectTrigger className="w-40">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">모든 역할</SelectItem>
+ <SelectItem value="owner">Owner</SelectItem>
+ <SelectItem value="admin">Admin</SelectItem>
+ <SelectItem value="editor">Editor</SelectItem>
+ <SelectItem value="viewer">Viewer</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 멤버 목록 (Table) */}
+ <div className="overflow-x-auto">
+ <Table className="[&_td]:py-2 [&_th]:py-2 text-sm">
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead className="w-[44px]"></TableHead>
+ <TableHead className="w-[100px]">이름</TableHead>
+ <TableHead className="min-w-[150px]">이메일</TableHead>
+ <TableHead className="w-[90px] text-center">구분</TableHead>
+ <TableHead className="w-[140px]">역할</TableHead>
+ <TableHead className="w-[130px]">추가일</TableHead>
+ <TableHead className="w-[150px]">마지막 접속</TableHead>
+ <TableHead className="w-[60px] text-right">액션</TableHead>
+ </TableRow>
+ </TableHeader>
+
+ <TableBody>
+ {paginatedMembers.length > 0 ? (
+ paginatedMembers.map((member) => {
+ const config = roleConfig[member.role];
+ const Icon = config.icon;
+ const isInternal = member.user.domain !== 'partners';
+
+ return (
+ <TableRow key={member.id} className="hover:bg-accent/40">
+ {/* Avatar */}
+ <TableCell className="align-middle">
+ <Avatar className="h-8 w-8">
+ <AvatarImage src={member.user.imageUrl} />
+ <AvatarFallback>
+ {member.user.name?.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ </TableCell>
+
+ {/* Name */}
+ <TableCell className="align-middle">
+ <span className="font-medium">{member.user.name}</span>
+ </TableCell>
+
+ {/* Email */}
+ <TableCell className="align-middle">
+ <span className="text-muted-foreground">{member.user.email}</span>
+ </TableCell>
+
+ {/* Domain */}
+ <TableCell className="align-middle text-center">
+ <Badge variant={isInternal ? 'secondary' : 'outline'}>
+ {isInternal ? 'Internal' : 'Partner'}
+ </Badge>
+ </TableCell>
+
+ {/* Role */}
+ <TableCell className="align-middle">
+ {canManageMembers && member.role !== 'owner' && member.user.domain !== 'partners' ? (
+ <Select
+ value={member.role}
+ onValueChange={(v) => updateMemberRole(member.id, v)}
+ >
+ <SelectTrigger className="h-8 w-[120px]">
+ <div className={cn('flex items-center gap-1')}>
+ <Icon className={cn('h-3 w-3', config.color)} />
+ <span className={cn('text-xs font-medium')}>
+ {config.label}
+ </span>
+ </div>
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer</SelectItem>
+ <SelectItem value="editor">Editor</SelectItem>
+ <SelectItem value="admin">Admin</SelectItem>
+ </SelectContent>
+ </Select>
+ ) : (
+ <div className="inline-flex items-center gap-2">
+ <div className={cn('px-2 py-1 rounded-full inline-flex items-center gap-1', config.bg)}>
+ <Icon className={cn('h-3 w-3', config.color)} />
+ <span className={cn('text-xs font-medium', config.color)}>
+ {config.label}
+ </span>
+ </div>
+ {member.user.domain === 'partners' && canManageMembers && member.role !== 'owner' && (
+ <span className="text-xs text-muted-foreground">(고정)</span>
+ )}
+ </div>
+ )}
+ </TableCell>
+
+ {/* AddedAt */}
+ <TableCell className="align-middle">
+ {formatDateShort(member.addedAt)}
+ </TableCell>
+
+ {/* LastAccess */}
+ <TableCell className="align-middle">
+ {formatDateShort(member.lastAccess)}
+ </TableCell>
+
+ {/* Actions */}
+ <TableCell className="align-middle">
+ <div className="flex justify-end">
+ {canManageMembers && member.role !== 'owner' ? (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem>
+ <Mail className="h-4 w-4 mr-2" />
+ 메일 보내기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ className="text-red-600"
+ onClick={() => removeMember(member.id)}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ 제거
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ) : (
+ <Button variant="ghost" size="icon" disabled>
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </TableCell>
+ </TableRow>
+ );
+ })
+ ) : (
+ <TableRow>
+ <TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
+ <div className="flex flex-col items-center justify-center gap-2">
+ <Users className="h-8 w-8 text-muted-foreground/60" />
+ <span>검색 결과가 없습니다</span>
+ </div>
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* Pagination */}
+ <div className="flex items-center justify-between px-4 py-3 border-t">
+ <div className="text-sm text-muted-foreground">
+ 총 {filteredMembers.length}명 · {pageSize}명/페이지
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >
+ 이전
+ </Button>
+ <span className="text-sm">
+ {page} / {totalPages}
+ </span>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page === totalPages}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+
+ {/* 멤버 추가 다이얼로그 */}
+ <Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
+ <DialogContent className="max-w-lg">
+ <DialogHeader>
+ <DialogTitle>멤버 추가</DialogTitle>
+ <DialogDescription>
+ 프로젝트에 멤버를 추가합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="internal" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="internal">내부 사용자</TabsTrigger>
+ <TabsTrigger value="external" className="flex items-center gap-2">
+ 외부 사용자
+ <Badge variant="outline" className="ml-1 text-xs">Viewer 전용</Badge>
+ </TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="internal" className="space-y-4 mt-4">
+ <div className="space-y-2">
+ <Label htmlFor="internal-user">사용자 선택</Label>
+
+ {loadingUsers ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span>
+ </div>
+ ) : (
+ <>
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={userPopoverOpen}
+ className="w-full justify-between"
+ >
+ <span className="truncate">
+ {selectedUser && selectedUser.domain !== 'partners' ? (
+ <div className="text-left">
+ <div className="font-medium">{selectedUser.name}</div>
+ <div className="text-xs text-muted-foreground">{selectedUser.email}</div>
+ </div>
+ ) : (
+ "내부 사용자를 선택하세요..."
+ )}
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[460px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="이름 또는 이메일로 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList
+ className="max-h-[300px]"
+ onWheel={(e) => {
+ e.stopPropagation();
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY;
+ }}
+ >
+ <CommandEmpty>사용자를 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup heading="내부 사용자 목록">
+ {filteredUsers
+ .filter(u => u.domain !== 'partners')
+ .map((user) => (
+ <CommandItem
+ key={user.id}
+ onSelect={() => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+ setIsExternalUser(false);
+ setNewMemberRole('viewer');
+ }}
+ value={`${user.name} ${user.email}`}
+ className="truncate"
+ >
+ <Users className="mr-2 h-4 w-4 text-blue-500 flex-shrink-0" />
+ <div className="flex-1 truncate">
+ <div className="font-medium truncate">{user.name}</div>
+ <div className="text-xs text-muted-foreground truncate">{user.email}</div>
+ </div>
+ <Check
+ className={cn(
+ "ml-2 h-4 w-4 flex-shrink-0",
+ selectedUser?.id === user.id && !isExternalUser ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ <p className="text-xs text-muted-foreground">
+ 내부 사용자는 모든 역할을 부여할 수 있습니다.
+ </p>
+ </>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="internal-role">역할</Label>
+ <Select
+ value={newMemberRole}
+ onValueChange={setNewMemberRole}
+ disabled={!selectedUser || isExternalUser}
+ >
+ <SelectTrigger id="internal-role">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem>
+ <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem>
+ <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </TabsContent>
+
+ <TabsContent value="external" className="space-y-4 mt-4">
+ <div className="rounded-lg bg-amber-50 border border-amber-200 p-3 mb-4">
+ <p className="text-sm text-amber-800">
+ <strong>보안 정책 안내</strong><br/>
+ 외부 사용자(파트너)는 보안 정책상 Viewer 권한만 부여 가능합니다.
+ </p>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="external-user">파트너 선택</Label>
+
+ {loadingUsers ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span>
+ </div>
+ ) : (
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={userPopoverOpen}
+ className="w-full justify-between"
+ >
+ <span className="truncate">
+ {selectedUser && selectedUser.domain === 'partners' ? (
+ <span className="flex items-center gap-2">
+ {selectedUser.name}
+ <Badge variant="outline" className="ml-1 text-xs">외부</Badge>
+ </span>
+ ) : (
+ "외부 사용자를 선택하세요..."
+ )}
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[460px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="이름으로 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList
+ className="max-h-[300px]"
+ onWheel={(e) => {
+ e.stopPropagation();
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY;
+ }}
+ >
+ <CommandEmpty>파트너를 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup heading="파트너 목록">
+ {filteredUsers
+ .filter(u => u.domain === 'partners')
+ .map((user) => (
+ <CommandItem
+ key={user.id}
+ onSelect={() => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+ setIsExternalUser(true);
+ setNewMemberRole('viewer');
+ }}
+ value={user.name}
+ className="truncate"
+ >
+ <Users className="mr-2 h-4 w-4 text-amber-600" />
+ <span className="truncate flex-1">{user.name}</span>
+ <Badge variant="outline" className="text-xs mx-2">파트너</Badge>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ selectedUser?.id === user.id && isExternalUser ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="external-role">역할</Label>
+ <Select value="viewer" disabled>
+ <SelectTrigger id="external-role" className="opacity-60">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer - 읽기 전용 (고정)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </TabsContent>
+ </Tabs>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setAddMemberOpen(false);
+ setSelectedUser(null);
+ setUserSearchTerm('');
+ setNewMemberRole('viewer');
+ setIsExternalUser(false);
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={addMember}
+ disabled={!selectedUser}
+ >
+ 추가하기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx
new file mode 100644
index 00000000..d54a8cab
--- /dev/null
+++ b/app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx
@@ -0,0 +1,10 @@
+// app/projects/[projectId]/page.tsx
+import { ProjectDashboard } from '@/components/project/ProjectDashboard';
+
+export default function ProjectPage({
+ params,
+}: {
+ params: { projectId: string };
+}) {
+ return <ProjectDashboard projectId={params.projectId} />;
+}
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx
new file mode 100644
index 00000000..aa0f3b52
--- /dev/null
+++ b/app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx
@@ -0,0 +1,488 @@
+
+// app/projects/[projectId]/settings/page.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+ Settings,
+ Shield,
+ Globe,
+ Trash2,
+ AlertCircle,
+ Save,
+ Lock,
+ Unlock,
+ Archive,
+ Users,
+ HardDrive
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { Textarea } from '@/components/ui/textarea';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { useToast } from '@/hooks/use-toast';
+import { useRouter } from 'next/navigation';
+
+interface ProjectSettings {
+ id: string;
+ name: string;
+ description: string;
+ isPublic: boolean;
+ externalAccessEnabled: boolean;
+ storageLimit: number;
+ maxFileSize: number;
+ allowedFileTypes: string[];
+ autoArchiveDays: number;
+ requireApproval: boolean;
+ defaultCategory: string;
+}
+
+export default function ProjectSettingsPage({
+ params
+}: {
+ params: { projectId: string }
+}) {
+ const [settings, setSettings] = useState<ProjectSettings | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
+ const [currentUserRole, setCurrentUserRole] = useState<string>('viewer');
+
+ const { toast } = useToast();
+ const router = useRouter();
+
+ useEffect(() => {
+ fetchSettings();
+ checkUserRole();
+ }, [params.projectId]);
+
+ const fetchSettings = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(`/api/projects/${params.projectId}/settings`);
+
+ if (!response.ok) {
+ throw new Error('설정을 불러올 수 없습니다');
+ }
+
+ const data = await response.json();
+ setSettings(data);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 설정을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const checkUserRole = async () => {
+ try {
+ const response = await fetch(`/api/projects/${params.projectId}/access`);
+ const data = await response.json();
+ setCurrentUserRole(data.role);
+ } catch (error) {
+ console.error('권한 확인 실패:', error);
+ }
+ };
+
+ const saveSettings = async () => {
+ if (!settings) return;
+
+ try {
+ setSaving(true);
+ const response = await fetch(`/api/projects/${params.projectId}/settings`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(settings),
+ });
+
+ if (!response.ok) throw new Error('설정 저장 실패');
+
+ toast({
+ title: '성공',
+ description: '프로젝트 설정이 저장되었습니다.',
+ });
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '설정 저장에 실패했습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const deleteProject = async () => {
+ try {
+ const response = await fetch(`/api/projects/${params.projectId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) throw new Error('프로젝트 삭제 실패');
+
+ toast({
+ title: '성공',
+ description: '프로젝트가 삭제되었습니다.',
+ });
+
+ router.push('/projects');
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 삭제에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const archiveProject = async () => {
+ try {
+ const response = await fetch(`/api/projects/${params.projectId}/archive`, {
+ method: 'POST',
+ });
+
+ if (!response.ok) throw new Error('프로젝트 보관 실패');
+
+ toast({
+ title: '성공',
+ description: '프로젝트가 보관되었습니다.',
+ });
+
+ router.push('/projects');
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 보관에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const canEdit = currentUserRole === 'owner' || currentUserRole === 'admin';
+
+ if (loading || !settings) {
+ return (
+ <div className="p-6">
+ <div className="animate-pulse space-y-4">
+ {[...Array(5)].map((_, i) => (
+ <div key={i} className="h-20 bg-gray-200 rounded" />
+ ))}
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="p-6 space-y-6 max-w-4xl">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold">프로젝트 설정</h1>
+ <p className="text-muted-foreground mt-1">
+ 프로젝트 설정을 관리합니다
+ </p>
+ </div>
+
+ {canEdit && (
+ <Button onClick={saveSettings} disabled={saving}>
+ <Save className="h-4 w-4 mr-2" />
+ {saving ? '저장 중...' : '변경사항 저장'}
+ </Button>
+ )}
+ </div>
+
+ {!canEdit && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 프로젝트 설정을 변경하려면 Owner 또는 Admin 권한이 필요합니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ <Tabs defaultValue="general">
+ <TabsList>
+ <TabsTrigger value="general">일반</TabsTrigger>
+ <TabsTrigger value="access">접근 관리</TabsTrigger>
+ <TabsTrigger value="storage">스토리지</TabsTrigger>
+ {currentUserRole === 'owner' && (
+ <TabsTrigger value="danger">위험 영역</TabsTrigger>
+ )}
+ </TabsList>
+
+ <TabsContent value="general" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <Label htmlFor="name">프로젝트 이름</Label>
+ <Input
+ id="name"
+ value={settings.name}
+ onChange={(e) => setSettings({ ...settings, name: e.target.value })}
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="description">설명</Label>
+ <Textarea
+ id="description"
+ value={settings.description}
+ onChange={(e) => setSettings({ ...settings, description: e.target.value })}
+ disabled={!canEdit}
+ rows={3}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="category">기본 파일 카테고리</Label>
+ <Select
+ value={settings.defaultCategory}
+ onValueChange={(value) => setSettings({ ...settings, defaultCategory: value })}
+ disabled={!canEdit}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="public">Public - 공개</SelectItem>
+ <SelectItem value="restricted">Restricted - 제한</SelectItem>
+ <SelectItem value="confidential">Confidential - 기밀</SelectItem>
+ <SelectItem value="internal">Internal - 내부</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="access" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>접근 설정</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label htmlFor="public">공개 프로젝트</Label>
+ <p className="text-sm text-muted-foreground">
+ 모든 사용자가 이 프로젝트를 볼 수 있습니다
+ </p>
+ </div>
+ <Switch
+ id="public"
+ checked={settings.isPublic}
+ onCheckedChange={(checked) => setSettings({ ...settings, isPublic: checked })}
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label htmlFor="external">외부 사용자 접근 허용</Label>
+ <p className="text-sm text-muted-foreground">
+ 파트너사 사용자도 접근할 수 있습니다
+ </p>
+ </div>
+ <Switch
+ id="external"
+ checked={settings.externalAccessEnabled}
+ onCheckedChange={(checked) =>
+ setSettings({ ...settings, externalAccessEnabled: checked })
+ }
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label htmlFor="approval">멤버 승인 필요</Label>
+ <p className="text-sm text-muted-foreground">
+ 새 멤버 참여 시 관리자 승인이 필요합니다
+ </p>
+ </div>
+ <Switch
+ id="approval"
+ checked={settings.requireApproval}
+ onCheckedChange={(checked) =>
+ setSettings({ ...settings, requireApproval: checked })
+ }
+ disabled={!canEdit}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="storage" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>스토리지 설정</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <Label htmlFor="storage-limit">스토리지 제한 (GB)</Label>
+ <Input
+ id="storage-limit"
+ type="number"
+ value={settings.storageLimit}
+ onChange={(e) => setSettings({
+ ...settings,
+ storageLimit: parseInt(e.target.value)
+ })}
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="file-size">최대 파일 크기 (MB)</Label>
+ <Input
+ id="file-size"
+ type="number"
+ value={settings.maxFileSize}
+ onChange={(e) => setSettings({
+ ...settings,
+ maxFileSize: parseInt(e.target.value)
+ })}
+ disabled={!canEdit}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="auto-archive">자동 보관 (일)</Label>
+ <Input
+ id="auto-archive"
+ type="number"
+ value={settings.autoArchiveDays}
+ onChange={(e) => setSettings({
+ ...settings,
+ autoArchiveDays: parseInt(e.target.value)
+ })}
+ disabled={!canEdit}
+ />
+ <p className="text-sm text-muted-foreground mt-1">
+ 설정한 기간 동안 접근하지 않은 파일을 자동으로 보관합니다
+ </p>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {currentUserRole === 'owner' && (
+ <TabsContent value="danger" className="space-y-4">
+ <Card className="border-red-200">
+ <CardHeader>
+ <CardTitle className="text-red-600">위험 영역</CardTitle>
+ <CardDescription>
+ 이 작업들은 되돌릴 수 없습니다. 신중하게 진행하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-between p-4 border rounded-lg">
+ <div>
+ <h3 className="font-medium">프로젝트 보관</h3>
+ <p className="text-sm text-muted-foreground">
+ 프로젝트를 보관하면 읽기 전용이 됩니다
+ </p>
+ </div>
+ <Button
+ variant="outline"
+ onClick={() => setArchiveDialogOpen(true)}
+ >
+ <Archive className="h-4 w-4 mr-2" />
+ 프로젝트 보관
+ </Button>
+ </div>
+
+ <div className="flex items-center justify-between p-4 border rounded-lg border-red-200">
+ <div>
+ <h3 className="font-medium text-red-600">프로젝트 삭제</h3>
+ <p className="text-sm text-muted-foreground">
+ 프로젝트와 모든 파일을 영구적으로 삭제합니다
+ </p>
+ </div>
+ <Button
+ variant="destructive"
+ onClick={() => setDeleteDialogOpen(true)}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ 프로젝트 삭제
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ )}
+ </Tabs>
+
+ {/* 삭제 확인 다이얼로그 */}
+ <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>프로젝트 삭제</DialogTitle>
+ <DialogDescription className="text-red-600">
+ 정말로 이 프로젝트를 삭제하시겠습니까?
+ 모든 파일과 데이터가 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
+ 취소
+ </Button>
+ <Button variant="destructive" onClick={deleteProject}>
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 보관 확인 다이얼로그 */}
+ <Dialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>프로젝트 보관</DialogTitle>
+ <DialogDescription>
+ 프로젝트를 보관하시겠습니까?
+ 보관된 프로젝트는 읽기 전용이 되며, 언제든지 복원할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setArchiveDialogOpen(false)}>
+ 취소
+ </Button>
+ <Button onClick={archiveProject}>
+ 보관
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx
new file mode 100644
index 00000000..7f652a99
--- /dev/null
+++ b/app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx
@@ -0,0 +1,373 @@
+// app/projects/[projectId]/stats/page.tsx
+'use client';
+
+import { use, useState, useEffect } from 'react';
+import {
+ BarChart3,
+ TrendingUp,
+ HardDrive,
+ Users,
+ Eye,
+ Download,
+ Upload,
+ Calendar,
+ FileText,
+ FolderOpen,
+ Activity
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Badge } from '@/components/ui/badge';
+import { Progress } from '@/components/ui/progress';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+
+interface ProjectStats {
+ storage: {
+ used: number;
+ limit: number;
+ fileCount: number;
+ folderCount: number;
+ byCategory: {
+ public: number;
+ restricted: number;
+ confidential: number;
+ internal: number;
+ };
+ };
+ activity: {
+ views: number;
+ downloads: number;
+ uploads: number;
+ shares: number;
+ trend: number; // 증감률
+ };
+ users: {
+ total: number;
+ active: number;
+ byRole: {
+ admin: number;
+ editor: number;
+ viewer: number;
+ };
+ };
+ recent: {
+ type: string;
+ user: string;
+ action: string;
+ timestamp: string;
+ details: string;
+ }[];
+}
+
+export default function ProjectStatsPage({
+ params
+}: {
+ params: Promise<{ projectId: string }>
+}) {
+ // Next.js 15에서 params를 unwrap
+ const resolvedParams = use(params);
+ const projectId = resolvedParams.projectId;
+
+ const [stats, setStats] = useState<ProjectStats | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [dateRange, setDateRange] = useState('30d');
+ const { toast } = useToast();
+
+ useEffect(() => {
+ fetchStats();
+ }, [projectId, dateRange]);
+
+ const fetchStats = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(
+ `/api/projects/${projectId}/stats?range=${dateRange}`
+ );
+
+ if (!response.ok) {
+ if (response.status === 403) {
+ throw new Error('통계를 볼 권한이 없습니다');
+ }
+ throw new Error('통계 로드 실패');
+ }
+
+ const data = await response.json();
+ setStats(data);
+ } catch (error: any) {
+ toast({
+ title: '오류',
+ description: error.message || '통계를 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const formatBytes = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ const formatNumber = (num: number) => {
+ return new Intl.NumberFormat('ko-KR').format(num);
+ };
+
+ if (loading) {
+ return (
+ <div className="p-6">
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+ {[...Array(8)].map((_, i) => (
+ <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" />
+ ))}
+ </div>
+ </div>
+ );
+ }
+
+ if (!stats) {
+ return (
+ <div className="p-6 text-center">
+ <BarChart3 className="h-12 w-12 mx-auto mb-3 text-muted-foreground" />
+ <p className="text-muted-foreground">통계를 불러올 수 없습니다</p>
+ </div>
+ );
+ }
+
+ const storagePercentage = (stats.storage.used / stats.storage.limit) * 100;
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold">프로젝트 통계</h1>
+ <p className="text-muted-foreground mt-1">
+ 프로젝트 사용 현황과 활동 내역을 확인합니다
+ </p>
+ </div>
+
+ <Tabs value={dateRange} onValueChange={setDateRange}>
+ <TabsList>
+ <TabsTrigger value="7d">7일</TabsTrigger>
+ <TabsTrigger value="30d">30일</TabsTrigger>
+ <TabsTrigger value="90d">90일</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ </div>
+
+ {/* 주요 지표 */}
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">스토리지 사용량</CardTitle>
+ <HardDrive className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {formatBytes(stats.storage.used)}
+ </div>
+ {/* <Progress value={storagePercentage} className="mt-2" /> */}
+ {/* <p className="text-xs text-muted-foreground mt-1">
+ 총 {formatBytes(stats.storage.limit)} 중 {storagePercentage.toFixed(1)}% 사용
+ </p> */}
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">파일 수</CardTitle>
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {formatNumber(stats.storage.fileCount)}
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 폴더 {formatNumber(stats.storage.folderCount)}개 포함
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">활성 사용자</CardTitle>
+ <Users className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {stats.users.active}
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 전체 {stats.users.total}명 중
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">총 다운로드</CardTitle>
+ <Download className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {formatNumber(stats.activity.downloads)}
+ </div>
+ <div className="flex items-center gap-1 mt-1">
+ {stats.activity.trend > 0 ? (
+ <TrendingUp className="h-3 w-3 text-green-500" />
+ ) : (
+ <TrendingUp className="h-3 w-3 text-red-500 rotate-180" />
+ )}
+ <span className={cn(
+ "text-xs",
+ stats.activity.trend > 0 ? "text-green-500" : "text-red-500"
+ )}>
+ {Math.abs(stats.activity.trend)}%
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 상세 통계 */}
+ <div className="grid gap-6 md:grid-cols-2">
+ {/* 파일 카테고리 분포 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>파일 카테고리</CardTitle>
+ <CardDescription>카테고리별 파일 분포</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-green-500 rounded-full" />
+ <span className="text-sm">Public</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.public}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-yellow-500 rounded-full" />
+ <span className="text-sm">Restricted</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.restricted}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-red-500 rounded-full" />
+ <span className="text-sm">Confidential</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.confidential}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-blue-500 rounded-full" />
+ <span className="text-sm">Internal</span>
+ </div>
+ <span className="text-sm font-medium">
+ {stats.storage.byCategory.internal}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 활동 요약 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>활동 요약</CardTitle>
+ <CardDescription>기간별 활동 내역</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Eye className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">조회수</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.views)}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Download className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">다운로드</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.downloads)}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Upload className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">업로드</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.uploads)}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Users className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">공유</span>
+ </div>
+ <span className="text-sm font-medium">
+ {formatNumber(stats.activity.shares)}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+{/* 최근 활동 */}
+<Card>
+ <CardHeader>
+ <CardTitle>최근 활동</CardTitle>
+ <CardDescription>프로젝트 내 최근 활동 내역</CardDescription>
+ </CardHeader>
+
+ {/* 패딩이 스크롤에 포함되도록 CardContent p-0 + 내부 래퍼에 패딩 */}
+ <CardContent className="p-0">
+ <div
+ className="max-h-80 md:max-h-96 xl:max-h-[480px] overflow-y-auto px-6 pb-6"
+ style={{ scrollbarGutter: "stable" }} // 스크롤바 생겨도 레이아웃 흔들림 방지
+ aria-label="최근 활동 스크롤 영역"
+ tabIndex={0} // 키보드 포커스 가능
+ >
+ <ul role="list" className="divide-y">
+ {stats.recent.map((activity, index) => (
+ <li key={index} className="flex items-center gap-3 py-3">
+ <Activity className="h-4 w-4 text-muted-foreground shrink-0" />
+ <div className="min-w-0 flex-1">
+ <p className="text-sm">
+ <span className="font-medium">{activity.user}</span>
+ {" "}님이{" "}
+ <span className="font-medium">{activity.details}</span>
+ {activity.action === "upload" && "을(를) 업로드했습니다"}
+ {activity.action === "download" && "을(를) 다운로드했습니다"}
+ {activity.action === "view" && "을(를) 조회했습니다"}
+ {activity.action === "share" && "을(를) 공유했습니다"}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {new Date(activity.timestamp).toLocaleString()}
+ </p>
+ </div>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </CardContent>
+</Card>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/data-room/page.tsx b/app/[lng]/partners/(partners)/data-room/page.tsx
new file mode 100644
index 00000000..4ff56abc
--- /dev/null
+++ b/app/[lng]/partners/(partners)/data-room/page.tsx
@@ -0,0 +1,26 @@
+// app/projects/page.tsx
+import { Suspense } from 'react';
+import { ProjectHeader } from '@/components/project/ProjectHeader';
+import { ProjectList } from '@/components/project/ProjectList';
+
+export default function ProjectsPage() {
+ return (
+ <div className="min-h-screen">
+ <div className="container mx-auto px-4 py-8 max-w-7xl">
+ <Suspense fallback={<ProjectListSkeleton />}>
+ <ProjectList />
+ </Suspense>
+ </div>
+ </div>
+ );
+}
+
+function ProjectListSkeleton() {
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+ {[...Array(6)].map((_, i) => (
+ <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" />
+ ))}
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/shared/[token]/page.tsx b/app/[lng]/shared/[token]/page.tsx
new file mode 100644
index 00000000..db7f5d7a
--- /dev/null
+++ b/app/[lng]/shared/[token]/page.tsx
@@ -0,0 +1,15 @@
+// app/shared/[token]/page.tsx
+
+import { SharedFileViewer } from "@/components/file-manager/SharedFileViewer";
+
+export default function SharedFilePage({
+ params,
+}: {
+ params: { token: string };
+}) {
+ return (
+ <div className="min-h-screen bg-gray-50">
+ <SharedFileViewer token={params.token} />
+ </div>
+ );
+}
diff --git a/app/api/data-room/[projectId]/[fileId]/download/route.ts b/app/api/data-room/[projectId]/[fileId]/download/route.ts
new file mode 100644
index 00000000..3a3a8fdd
--- /dev/null
+++ b/app/api/data-room/[projectId]/[fileId]/download/route.ts
@@ -0,0 +1,246 @@
+// app/api/data-room/[projectId]/[fileId]/download/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { FileService, type FileAccessContext } from '@/lib/services/fileService';
+import { promises as fs } from 'fs';
+import path from 'path';
+import db from "@/db/db";
+import { fileItems } from "@/db/schema/fileSystem";
+import { eq } from "drizzle-orm";
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { projectId: string; fileId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const context: FileAccessContext = {
+ userId: Number(session.user.id),
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+
+ // 파일 접근 권한 확인
+ const hasAccess = await fileService.checkFileAccess(
+ params.fileId,
+ context,
+ 'download'
+ );
+
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: '파일 다운로드 권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // FileService를 통해 파일 정보 가져오기 (다운로드 카운트 증가 및 로그 기록)
+ const file = await fileService.downloadFile(params.fileId, context);
+
+ if (!file) {
+ return NextResponse.json(
+ { error: '파일을 찾을 수 없습니다' },
+ { status: 404 }
+ );
+ }
+
+ // 파일 경로 확인
+ if (!file.filePath) {
+ return NextResponse.json(
+ { error: '파일 경로가 없습니다' },
+ { status: 404 }
+ );
+ }
+
+ // 실제 파일 경로 구성
+ const nasPath = process.env.NAS_PATH || "/evcp_nas";
+ const isProduction = process.env.NODE_ENV === "production";
+
+ let absolutePath: string;
+ if (isProduction) {
+ // 프로덕션: NAS 경로 사용
+ const relativePath = file.filePath.replace('/api/files/', '');
+ absolutePath = path.join(nasPath, relativePath);
+ } else {
+ // 개발: public 폴더 사용
+ absolutePath = path.join(process.cwd(), 'public', file.filePath);
+ }
+
+ // 파일 존재 여부 확인
+ try {
+ await fs.access(absolutePath);
+ } catch (error) {
+ console.error('파일을 찾을 수 없습니다:', absolutePath);
+ return NextResponse.json(
+ { error: '파일을 찾을 수 없습니다' },
+ { status: 404 }
+ );
+ }
+
+ // 파일 읽기
+ const fileBuffer = await fs.readFile(absolutePath);
+
+ // MIME 타입 결정
+ const mimeType = getMimeType(file.name, file.mimeType);
+
+ // 파일명 인코딩 (한글 등 특수문자 처리)
+ const encodedFileName = encodeURIComponent(file.name);
+
+ // Response Headers 설정
+ const headers = new Headers();
+ headers.set('Content-Type', mimeType);
+ headers.set('Content-Length', fileBuffer.length.toString());
+ headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
+ headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
+ headers.set('Pragma', 'no-cache');
+ headers.set('Expires', '0');
+
+ // 보안 헤더 추가
+ headers.set('X-Content-Type-Options', 'nosniff');
+ headers.set('X-Frame-Options', 'DENY');
+ headers.set('X-XSS-Protection', '1; mode=block');
+
+ // 파일 스트림 반환
+ return new NextResponse(fileBuffer, {
+ status: 200,
+ headers,
+ });
+
+ } catch (error) {
+ console.error('파일 다운로드 오류:', error);
+
+ if (error instanceof Error) {
+ if (error.message.includes('권한')) {
+ return NextResponse.json(
+ { error: error.message },
+ { status: 403 }
+ );
+ }
+ }
+
+ return NextResponse.json(
+ { error: '파일 다운로드에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+// HEAD 요청 처리 (파일 정보만 확인)
+export async function HEAD(
+ request: NextRequest,
+ { params }: { params: { projectId: string; fileId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return new NextResponse(null, { status: 401 });
+ }
+
+ const context: FileAccessContext = {
+ userId: Number(session.user.id),
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+
+ // 파일 접근 권한 확인
+ const hasAccess = await fileService.checkFileAccess(
+ params.fileId,
+ context,
+ 'view' // HEAD 요청은 view 권한만 확인
+ );
+
+ if (!hasAccess) {
+ return new NextResponse(null, { status: 403 });
+ }
+
+ // 파일 정보 조회
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, params.fileId),
+ });
+
+ if (!file || !file.filePath) {
+ return new NextResponse(null, { status: 404 });
+ }
+
+ const headers = new Headers();
+ headers.set('Content-Type', getMimeType(file.name, file.mimeType));
+ headers.set('Content-Length', file.size?.toString() || '0');
+ headers.set('Last-Modified', new Date(file.updatedAt).toUTCString());
+
+ return new NextResponse(null, {
+ status: 200,
+ headers,
+ });
+
+ } catch (error) {
+ console.error('HEAD 요청 오류:', error);
+ return new NextResponse(null, { status: 500 });
+ }
+}
+
+// MIME 타입 결정 헬퍼 함수
+function getMimeType(fileName: string, storedMimeType?: string | null): string {
+ // DB에 저장된 MIME 타입이 있으면 우선 사용
+ if (storedMimeType) {
+ return storedMimeType;
+ }
+
+ // 확장자 기반 MIME 타입 매핑
+ const ext = path.extname(fileName).toLowerCase().substring(1);
+ const mimeTypes: Record<string, string> = {
+ // Documents
+ 'pdf': 'application/pdf',
+ 'doc': 'application/msword',
+ 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'xls': 'application/vnd.ms-excel',
+ 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'ppt': 'application/vnd.ms-powerpoint',
+ 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'txt': 'text/plain',
+ 'csv': 'text/csv',
+
+ // Images
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'png': 'image/png',
+ 'gif': 'image/gif',
+ 'bmp': 'image/bmp',
+ 'webp': 'image/webp',
+ 'svg': 'image/svg+xml',
+
+ // Archives
+ 'zip': 'application/zip',
+ 'rar': 'application/x-rar-compressed',
+ '7z': 'application/x-7z-compressed',
+
+ // CAD
+ 'dwg': 'application/x-dwg',
+ 'dxf': 'application/x-dxf',
+
+ // Video
+ 'mp4': 'video/mp4',
+ 'avi': 'video/x-msvideo',
+ 'mov': 'video/quicktime',
+ 'wmv': 'video/x-ms-wmv',
+
+ // Audio
+ 'mp3': 'audio/mpeg',
+ 'wav': 'audio/wav',
+ 'ogg': 'audio/ogg',
+ };
+
+ return mimeTypes[ext] || 'application/octet-stream';
+} \ No newline at end of file
diff --git a/app/api/data-room/[projectId]/[fileId]/route.ts b/app/api/data-room/[projectId]/[fileId]/route.ts
new file mode 100644
index 00000000..176aaf63
--- /dev/null
+++ b/app/api/data-room/[projectId]/[fileId]/route.ts
@@ -0,0 +1,147 @@
+// app/api/files/[projectId]/[fileId]/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { FileService, type FileAccessContext } from '@/lib/services/fileService';
+
+// 파일 정보 조회
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { projectId: string; fileId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const context: FileAccessContext = {
+ userId: session.user.id,
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+ const hasAccess = await fileService.checkFileAccess(
+ params.fileId,
+ context,
+ 'view'
+ );
+
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: '파일 접근 권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // 파일 정보 반환
+ const file = await fileService.downloadFile(params.fileId, context);
+
+ if (!file) {
+ return NextResponse.json(
+ { error: '파일을 찾을 수 없습니다' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json(file);
+ } catch (error) {
+ console.error('파일 조회 오류:', error);
+ return NextResponse.json(
+ { error: '파일 조회에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+// 파일 수정
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: { projectId: string; fileId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const context: FileAccessContext = {
+ userId: session.user.id,
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+ const hasAccess = await fileService.checkFileAccess(
+ params.fileId,
+ context,
+ 'edit'
+ );
+
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: '파일 수정 권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ const body = await request.json();
+
+ // 파일 이동 처리
+ if (body.parentId !== undefined) {
+ await fileService.moveFile(params.fileId, body.parentId, context);
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('파일 수정 오류:', error);
+ return NextResponse.json(
+ { error: '파일 수정에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+// 파일 삭제
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { projectId: string; fileId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const context: FileAccessContext = {
+ userId: session.user.id,
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+ await fileService.deleteFile(params.fileId, context);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('권한')) {
+ return NextResponse.json(
+ { error: error.message },
+ { status: 403 }
+ );
+ }
+
+ console.error('파일 삭제 오류:', error);
+ return NextResponse.json(
+ { error: '파일 삭제에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts
new file mode 100644
index 00000000..bba7066f
--- /dev/null
+++ b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts
@@ -0,0 +1,289 @@
+// app/api/data-room/[projectId]/download-folder/[folderId]/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { FileService, type FileAccessContext } from '@/lib/services/fileService';
+import { promises as fs } from 'fs';
+import path from 'path';
+import archiver from 'archiver';
+import db from "@/db/db";
+import { fileItems } from "@/db/schema/fileSystem";
+import { eq, and } from "drizzle-orm";
+
+interface FileWithPath {
+ file: any;
+ absolutePath: string;
+ relativePath: string;
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { projectId: string; folderId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const context: FileAccessContext = {
+ userId: Number(session.user.id),
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ // 폴더 정보 가져오기
+ const folder = await db.query.fileItems.findFirst({
+ where: and(
+ eq(fileItems.id, params.folderId),
+ eq(fileItems.projectId, params.projectId)
+ ),
+ });
+
+ if (!folder || folder.type !== 'folder') {
+ return NextResponse.json(
+ { error: '폴더를 찾을 수 없습니다' },
+ { status: 404 }
+ );
+ }
+
+ const fileService = new FileService();
+ const downloadableFiles: FileWithPath[] = [];
+ const unauthorizedFiles: string[] = [];
+
+ // 재귀적으로 폴더 내 모든 파일 가져오기 및 권한 확인
+ const processFolder = async (
+ folderId: string,
+ folderPath: string = ''
+ ): Promise<void> => {
+ const items = await db.query.fileItems.findMany({
+ where: and(
+ eq(fileItems.parentId, folderId),
+ eq(fileItems.projectId, params.projectId)
+ ),
+ });
+
+ for (const item of items) {
+ if (item.type === 'file') {
+ // 파일 권한 확인
+ const hasAccess = await fileService.checkFileAccess(
+ item.id,
+ context,
+ 'download'
+ );
+
+ if (!hasAccess) {
+ // 권한이 없는 파일 기록
+ unauthorizedFiles.push(path.join(folderPath, item.name));
+ continue;
+ }
+
+ if (!item.filePath) continue;
+
+ // 실제 파일 경로 구성
+ const nasPath = process.env.NAS_PATH || "/evcp_nas";
+ const isProduction = process.env.NODE_ENV === "production";
+
+ let absolutePath: string;
+ if (isProduction) {
+ const relativePath = item.filePath.replace('/api/files/', '');
+ absolutePath = path.join(nasPath, relativePath);
+ } else {
+ absolutePath = path.join(process.cwd(), 'public', item.filePath);
+ }
+
+ // 파일 존재 여부 확인
+ try {
+ await fs.access(absolutePath);
+ downloadableFiles.push({
+ file: item,
+ absolutePath,
+ relativePath: path.join(folderPath, item.name)
+ });
+
+ // 다운로드 카운트 증가 및 로그 기록
+ await fileService.downloadFile(item.id, context);
+ } catch (error) {
+ console.warn(`파일을 찾을 수 없습니다: ${absolutePath}`);
+ }
+ } else if (item.type === 'folder') {
+ // 하위 폴더 재귀 처리
+ await processFolder(
+ item.id,
+ path.join(folderPath, item.name)
+ );
+ }
+ }
+ };
+
+ // 폴더 처리 시작
+ await processFolder(params.folderId, folder.name);
+
+ // 권한이 없는 파일이 있으면 다운로드 차단
+ if (unauthorizedFiles.length > 0) {
+ return NextResponse.json(
+ {
+ error: '일부 파일에 대한 다운로드 권한이 없습니다',
+ unauthorizedFiles: unauthorizedFiles,
+ unauthorizedCount: unauthorizedFiles.length,
+ message: `다음 파일들에 대한 권한이 없어 폴더 다운로드가 취소되었습니다: ${unauthorizedFiles.slice(0, 5).join(', ')}${unauthorizedFiles.length > 5 ? ` 외 ${unauthorizedFiles.length - 5}개` : ''}`
+ },
+ { status: 403 }
+ );
+ }
+
+ // 다운로드할 파일이 없는 경우
+ if (downloadableFiles.length === 0) {
+ return NextResponse.json(
+ { error: '다운로드 가능한 파일이 없습니다' },
+ { status: 404 }
+ );
+ }
+
+ // 파일 크기 합계 체크 (최대 500MB)
+ const totalSize = downloadableFiles.reduce((sum, item) =>
+ sum + (item.file.size || 0), 0
+ );
+
+ const maxSize = 500 * 1024 * 1024; // 500MB
+ if (totalSize > maxSize) {
+ return NextResponse.json(
+ {
+ error: `폴더 크기가 너무 큽니다 (${(totalSize / 1024 / 1024).toFixed(2)}MB). 최대 500MB까지 다운로드 가능합니다.`,
+ totalSize: totalSize,
+ maxSize: maxSize,
+ fileCount: downloadableFiles.length
+ },
+ { status: 400 }
+ );
+ }
+
+ console.log(`📦 폴더 다운로드 시작: ${folder.name} (${downloadableFiles.length}개 파일, ${(totalSize / 1024 / 1024).toFixed(2)}MB)`);
+
+ // ZIP 스트림 생성
+ const archive = archiver('zip', {
+ zlib: { level: 5 } // 압축 레벨
+ });
+
+ // 스트림을 Response로 변환
+ const stream = new ReadableStream({
+ start(controller) {
+ archive.on('data', (chunk) => controller.enqueue(chunk));
+ archive.on('end', () => controller.close());
+ archive.on('error', (err) => {
+ console.error('Archive error:', err);
+ controller.error(err);
+ });
+ },
+ });
+
+ // 파일들을 ZIP에 추가 (폴더 구조 유지)
+ for (const { file, absolutePath, relativePath } of downloadableFiles) {
+ try {
+ const fileBuffer = await fs.readFile(absolutePath);
+ archive.append(fileBuffer, { name: relativePath });
+ } catch (error) {
+ console.error(`파일 추가 실패: ${relativePath}`, error);
+ }
+ }
+
+ // ZIP 완료
+ archive.finalize();
+
+ // Response Headers 설정
+ const headers = new Headers();
+ headers.set('Content-Type', 'application/zip');
+ headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(folder.name)}.zip"`);
+ headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
+ headers.set('Pragma', 'no-cache');
+ headers.set('Expires', '0');
+ headers.set('X-File-Count', downloadableFiles.length.toString());
+ headers.set('X-Total-Size', totalSize.toString());
+
+ return new NextResponse(stream, {
+ status: 200,
+ headers,
+ });
+
+ } catch (error) {
+ console.error('폴더 다운로드 오류:', error);
+ return NextResponse.json(
+ { error: '폴더 다운로드에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+// 폴더 다운로드 전 권한 체크 (선택적)
+export async function HEAD(
+ request: NextRequest,
+ { params }: { params: { projectId: string; folderId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return new NextResponse(null, { status: 401 });
+ }
+
+ const context: FileAccessContext = {
+ userId: Number(session.user.id),
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+ let totalFiles = 0;
+ let unauthorizedCount = 0;
+ let totalSize = 0;
+
+ // 재귀적으로 권한 체크
+ const checkFolder = async (folderId: string): Promise<void> => {
+ const items = await db.query.fileItems.findMany({
+ where: and(
+ eq(fileItems.parentId, folderId),
+ eq(fileItems.projectId, params.projectId)
+ ),
+ });
+
+ for (const item of items) {
+ if (item.type === 'file') {
+ totalFiles++;
+ totalSize += item.size || 0;
+
+ const hasAccess = await fileService.checkFileAccess(
+ item.id,
+ context,
+ 'download'
+ );
+
+ if (!hasAccess) {
+ unauthorizedCount++;
+ }
+ } else if (item.type === 'folder') {
+ await checkFolder(item.id);
+ }
+ }
+ };
+
+ await checkFolder(params.folderId);
+
+ const headers = new Headers();
+ headers.set('X-Total-Files', totalFiles.toString());
+ headers.set('X-Unauthorized-Files', unauthorizedCount.toString());
+ headers.set('X-Total-Size', totalSize.toString());
+ headers.set('X-Can-Download', unauthorizedCount === 0 ? 'true' : 'false');
+
+ return new NextResponse(null, {
+ status: unauthorizedCount > 0 ? 403 : 200,
+ headers,
+ });
+
+ } catch (error) {
+ console.error('권한 체크 오류:', error);
+ return new NextResponse(null, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/data-room/[projectId]/download-multiple/route.ts b/app/api/data-room/[projectId]/download-multiple/route.ts
new file mode 100644
index 00000000..64c87b55
--- /dev/null
+++ b/app/api/data-room/[projectId]/download-multiple/route.ts
@@ -0,0 +1,162 @@
+// app/api/data-room/[projectId]/download-multiple/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { FileService, type FileAccessContext } from '@/lib/services/fileService';
+import { promises as fs } from 'fs';
+import path from 'path';
+import archiver from 'archiver';
+import { Readable } from 'stream';
+import db from "@/db/db";
+import { fileItems } from "@/db/schema/fileSystem";
+import { eq, inArray } from "drizzle-orm";
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const { fileIds } = body;
+
+ if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) {
+ return NextResponse.json(
+ { error: '파일 ID가 제공되지 않았습니다' },
+ { status: 400 }
+ );
+ }
+
+ // 너무 많은 파일 방지 (최대 100개)
+ if (fileIds.length > 100) {
+ return NextResponse.json(
+ { error: '한 번에 최대 100개의 파일만 다운로드할 수 있습니다' },
+ { status: 400 }
+ );
+ }
+
+ const context: FileAccessContext = {
+ userId: Number(session.user.id),
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+ const downloadableFiles: Array<{
+ file: any;
+ absolutePath: string;
+ }> = [];
+
+ // 각 파일의 접근 권한 확인 및 경로 확인
+ for (const fileId of fileIds) {
+ // 권한 확인
+ const hasAccess = await fileService.checkFileAccess(
+ fileId,
+ context,
+ 'download'
+ );
+
+ if (!hasAccess) {
+ console.warn(`파일 ${fileId}에 대한 다운로드 권한이 없습니다`);
+ continue;
+ }
+
+ // 파일 정보 가져오기
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, fileId),
+ });
+
+ if (!file || !file.filePath || file.type !== 'file') {
+ console.warn(`파일 ${fileId}를 찾을 수 없거나 폴더입니다`);
+ continue;
+ }
+
+ // 실제 파일 경로 구성
+ const nasPath = process.env.NAS_PATH || "/evcp_nas";
+ const isProduction = process.env.NODE_ENV === "production";
+
+ let absolutePath: string;
+ if (isProduction) {
+ const relativePath = file.filePath.replace('/api/files/', '');
+ absolutePath = path.join(nasPath, relativePath);
+ } else {
+ absolutePath = path.join(process.cwd(), 'public', file.filePath);
+ }
+
+ // 파일 존재 여부 확인
+ try {
+ await fs.access(absolutePath);
+ downloadableFiles.push({ file, absolutePath });
+
+ // 다운로드 카운트 증가 및 로그 기록
+ await fileService.downloadFile(fileId, context);
+ } catch (error) {
+ console.warn(`파일 ${absolutePath}를 찾을 수 없습니다`);
+ }
+ }
+
+ if (downloadableFiles.length === 0) {
+ return NextResponse.json(
+ { error: '다운로드 가능한 파일이 없습니다' },
+ { status: 404 }
+ );
+ }
+
+ // ZIP 스트림 생성
+ const archive = archiver('zip', {
+ zlib: { level: 5 } // 압축 레벨 (1-9, 5가 균형적)
+ });
+
+ // 스트림을 Response로 변환
+ const stream = new ReadableStream({
+ start(controller) {
+ archive.on('data', (chunk) => controller.enqueue(chunk));
+ archive.on('end', () => controller.close());
+ archive.on('error', (err) => controller.error(err));
+ },
+ });
+
+ // 파일들을 ZIP에 추가
+ for (const { file, absolutePath } of downloadableFiles) {
+ try {
+ const fileBuffer = await fs.readFile(absolutePath);
+
+ // 파일명 중복 방지를 위한 고유 이름 생성
+ const uniqueName = `${path.parse(file.name).name}_${file.id.slice(0, 8)}${path.extname(file.name)}`;
+
+ archive.append(fileBuffer, { name: uniqueName });
+ } catch (error) {
+ console.error(`파일 추가 실패: ${file.name}`, error);
+ }
+ }
+
+ // ZIP 완료
+ archive.finalize();
+
+ // Response Headers 설정
+ const headers = new Headers();
+ headers.set('Content-Type', 'application/zip');
+ headers.set('Content-Disposition', 'attachment; filename="files.zip"');
+ headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
+ headers.set('Pragma', 'no-cache');
+ headers.set('Expires', '0');
+
+ return new NextResponse(stream, {
+ status: 200,
+ headers,
+ });
+
+ } catch (error) {
+ console.error('다중 파일 다운로드 오류:', error);
+ return NextResponse.json(
+ { error: '다중 파일 다운로드에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/data-room/[projectId]/permissions/route.ts b/app/api/data-room/[projectId]/permissions/route.ts
new file mode 100644
index 00000000..94401826
--- /dev/null
+++ b/app/api/data-room/[projectId]/permissions/route.ts
@@ -0,0 +1,74 @@
+// app/api/files/[projectId]/permissions/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { FileService, type FileAccessContext } from '@/lib/services/fileService';
+import { z } from 'zod';
+
+const grantPermissionSchema = z.object({
+ fileId: z.string().uuid(),
+ targetUserId: z.number().optional().nullable(),
+ targetDomain: z.string().optional().nullable(),
+ permissions: z.object({
+ canView: z.boolean().optional(),
+ canDownload: z.boolean().optional(),
+ canEdit: z.boolean().optional(),
+ canDelete: z.boolean().optional(),
+ canShare: z.boolean().optional(),
+ }),
+});
+
+// 권한 부여
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const validatedData = grantPermissionSchema.parse(body);
+
+ const context: FileAccessContext = {
+ userId: session.user.id,
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+ await fileService.grantPermission(
+ validatedData.fileId,
+ validatedData.targetUserId,
+ validatedData.targetDomain,
+ validatedData.permissions,
+ context
+ );
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: '잘못된 요청 데이터', details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ if (error instanceof Error && error.message.includes('권한')) {
+ return NextResponse.json(
+ { error: error.message },
+ { status: 403 }
+ );
+ }
+
+ console.error('권한 부여 오류:', error);
+ return NextResponse.json(
+ { error: '권한 부여에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/data-room/[projectId]/route.ts b/app/api/data-room/[projectId]/route.ts
new file mode 100644
index 00000000..643dcf0f
--- /dev/null
+++ b/app/api/data-room/[projectId]/route.ts
@@ -0,0 +1,118 @@
+// app/api/data-room/[projectId]/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { FileService, type FileAccessContext } from '@/lib/services/fileService';
+import { z } from 'zod';
+
+// 파일 생성 스키마 검증
+const createFileSchema = z.object({
+ name: z.string().min(1).max(255),
+ type: z.enum(['file', 'folder']),
+ parentId: z.string().uuid().optional().nullable(),
+ category: z.enum(['public', 'restricted', 'confidential', 'internal']).default('confidential'),
+ mimeType: z.string().optional(),
+ size: z.number().optional(),
+ filePath: z.string().optional(),
+});
+
+// 파일 목록 조회
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const searchParams = request.nextUrl.searchParams;
+ const parentId = searchParams.get('parentId');
+ const viewMode = searchParams.get('viewMode'); // 'tree' or 'grid'
+ const includeAll = searchParams.get('includeAll') === 'true'; // 전체 목록 가져오기
+
+ const context: FileAccessContext = {
+ userId: Number(session.user.id),
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+
+ // viewMode가 tree이거나 includeAll이 true인 경우 전체 목록 가져오기
+ const files = await fileService.getFileList(
+ params.projectId,
+ parentId,
+ context,
+ {
+ includeAll: viewMode === 'tree' || includeAll
+ }
+ );
+
+ return NextResponse.json(files);
+ } catch (error) {
+ console.error('파일 목록 조회 오류:', error);
+ return NextResponse.json(
+ { error: '파일 목록을 불러올 수 없습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+// 파일/폴더 생성
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const validatedData = createFileSchema.parse(body);
+
+ const context: FileAccessContext = {
+ userId: Number(session.user.id),
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+ const newFile = await fileService.createFileItem(
+ {
+ ...validatedData,
+ projectId: params.projectId,
+ },
+ context
+ );
+
+ return NextResponse.json(newFile, { status: 201 });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: '잘못된 요청 데이터', details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ if (error instanceof Error && error.message === '권한이 없습니다') {
+ return NextResponse.json(
+ { error: '파일 생성 권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ console.error('파일 생성 오류:', error);
+ return NextResponse.json(
+ { error: '파일 생성에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/data-room/[projectId]/share/[token]/route.ts b/app/api/data-room/[projectId]/share/[token]/route.ts
new file mode 100644
index 00000000..51582bca
--- /dev/null
+++ b/app/api/data-room/[projectId]/share/[token]/route.ts
@@ -0,0 +1,45 @@
+// app/api/shared/[token]/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { FileService } from '@/lib/services/fileService';
+
+// 공유 링크로 파일 접근
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { token: string } }
+) {
+ try {
+ const searchParams = request.nextUrl.searchParams;
+ const password = searchParams.get('password');
+
+ const fileService = new FileService();
+ const result = await fileService.accessFileByShareToken(
+ params.token,
+ password || undefined
+ );
+
+ if (!result) {
+ return NextResponse.json(
+ { error: '유효하지 않은 공유 링크입니다' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({
+ file: result.file,
+ accessLevel: result.accessLevel,
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ return NextResponse.json(
+ { error: error.message },
+ { status: 400 }
+ );
+ }
+
+ console.error('공유 파일 접근 오류:', error);
+ return NextResponse.json(
+ { error: '파일 접근에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/data-room/[projectId]/share/route.ts b/app/api/data-room/[projectId]/share/route.ts
new file mode 100644
index 00000000..9b27d9fc
--- /dev/null
+++ b/app/api/data-room/[projectId]/share/route.ts
@@ -0,0 +1,79 @@
+// app/api/files/[projectId]/share/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { FileService, type FileAccessContext } from '@/lib/services/fileService';
+import { z } from 'zod';
+
+const createShareSchema = z.object({
+ fileId: z.string().uuid(),
+ accessLevel: z.enum(['view_only', 'view_download']).optional(),
+ password: z.string().optional(),
+ expiresAt: z.string().datetime().optional(),
+ maxDownloads: z.number().positive().optional(),
+ sharedWithEmail: z.string().email().optional(),
+});
+
+// 공유 링크 생성
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const validatedData = createShareSchema.parse(body);
+
+ const context: FileAccessContext = {
+ userId: session.user.id,
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ const fileService = new FileService();
+ const shareToken = await fileService.createShareLink(
+ validatedData.fileId,
+ {
+ ...validatedData,
+ expiresAt: validatedData.expiresAt
+ ? new Date(validatedData.expiresAt)
+ : undefined,
+ },
+ context
+ );
+
+ const shareUrl = `${process.env.NEXT_PUBLIC_APP_URL}/shared/${shareToken}`;
+
+ return NextResponse.json({
+ shareToken,
+ shareUrl,
+ success: true
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: '잘못된 요청 데이터', details: error.errors },
+ { status: 400 }
+ );
+ }
+
+ if (error instanceof Error && error.message.includes('권한')) {
+ return NextResponse.json(
+ { error: error.message },
+ { status: 403 }
+ );
+ }
+
+ console.error('공유 링크 생성 오류:', error);
+ return NextResponse.json(
+ { error: '공유 링크 생성에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/data-room/[projectId]/upload/route.ts b/app/api/data-room/[projectId]/upload/route.ts
new file mode 100644
index 00000000..60bbc10f
--- /dev/null
+++ b/app/api/data-room/[projectId]/upload/route.ts
@@ -0,0 +1,139 @@
+// app/api/data-room/[projectId]/upload/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { FileService, type FileAccessContext } from '@/lib/services/fileService';
+import { saveDRMFile, saveFileStream } from '@/lib/file-stroage';
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const context: FileAccessContext = {
+ userId: Number(session.user.id),
+ userDomain: session.user.domain || 'partners',
+ userEmail: session.user.email,
+ ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined,
+ userAgent: request.headers.get('user-agent') || undefined,
+ };
+
+ // 내부 사용자만 업로드 가능
+ if (session.user.domain === 'partners') {
+ return NextResponse.json(
+ { error: '파일 업로드 권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ const formData = await request.formData();
+ const file = formData.get('file') as File;
+ const category = formData.get('category') as string;
+ const parentId = formData.get('parentId') as string | null;
+ const fileSize = formData.get('fileSize') as string | null;
+
+ if (!file) {
+ return NextResponse.json(
+ { error: '파일이 제공되지 않았습니다' },
+ { status: 400 }
+ );
+ }
+
+ // 대용량 파일 임계값 (10MB)
+ const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024;
+ const actualFileSize = fileSize ? parseInt(fileSize) : file.size;
+
+ let result;
+
+ // 파일 크기에 따라 다른 저장 방법 사용
+ if (actualFileSize > LARGE_FILE_THRESHOLD) {
+ console.log(`🚀 대용량 파일 스트리밍 저장: ${file.name} (${(actualFileSize / 1024 / 1024).toFixed(2)}MB)`);
+
+ // 대용량 파일은 스트리밍 저장 사용
+ result = await saveFileStream({
+ file,
+ directory: `projects/${params.projectId}`,
+ originalName: file.name,
+ userId: session.user.id
+ });
+ } else {
+ console.log(`📦 일반 파일 저장: ${file.name} (${(actualFileSize / 1024 / 1024).toFixed(2)}MB)`);
+
+ // 작은 파일은 기존 DRM 저장 방식 사용
+ result = await saveDRMFile(
+ file,
+ async (file) => file.arrayBuffer(), // 이미 복호화된 데이터
+ `projects/${params.projectId}`,
+ session.user.id
+ );
+ }
+
+ if (!result.success) {
+ return NextResponse.json(
+ { error: result.error || '파일 저장에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+
+ // DB에 파일 정보 저장
+ const fileService = new FileService();
+ const newFile = await fileService.createFileItem(
+ {
+ name: result.originalName || file.name,
+ type: 'file',
+ category: category as 'public' | 'restricted' | 'confidential' | 'internal',
+ parentId,
+ size: result.fileSize || actualFileSize,
+ mimeType: file.type,
+ filePath: result.publicPath,
+ projectId: params.projectId,
+ },
+ context
+ );
+
+ return NextResponse.json({
+ ...newFile,
+ uploadResult: {
+ ...result,
+ uploadMethod: actualFileSize > LARGE_FILE_THRESHOLD ? 'stream' : 'buffer',
+ fileSizeMB: (actualFileSize / 1024 / 1024).toFixed(2)
+ },
+ }, { status: 201 });
+
+ } catch (error) {
+ console.error('파일 업로드 오류:', error);
+ return NextResponse.json(
+ {
+ error: '파일 업로드에 실패했습니다',
+ details: error instanceof Error ? error.message : undefined
+ },
+ { status: 500 }
+ );
+ }
+}
+
+// 업로드 진행률 확인 (선택적)
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ // 업로드 상태 확인 로직 (필요시 구현)
+ return NextResponse.json({ message: '업로드 상태 확인 엔드포인트' });
+ } catch (error) {
+ return NextResponse.json(
+ { error: '상태 확인 실패' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts
index 88211f5b..89f00a3c 100644
--- a/app/api/files/[...path]/route.ts
+++ b/app/api/files/[...path]/route.ts
@@ -51,7 +51,8 @@ const isAllowedPath = (requestedPath: string): boolean => {
'vendors',
'pq',
'pq/vendor',
- 'information'
+ 'information',
+ 'general-contract-templates'
];
return allowedPaths.some(allowed =>
diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts
index 1fc9d5dd..21a4e7a4 100644
--- a/app/api/partners/rfq-last/[id]/response/route.ts
+++ b/app/api/partners/rfq-last/[id]/response/route.ts
@@ -264,6 +264,8 @@ export async function PUT(
vendorDeliveryDate: data.vendorDeliveryDate ? new Date(data.vendorDeliveryDate) : null,
submittedAt: data.submittedAt ? new Date(data.submittedAt) : null,
responseVersion: existingResponse.responseVersion + 1,
+ status:"제출완료",
+ participationStatus: "참여",
isLatest: true,
createdBy: existingResponse.createdBy,
updatedBy: session.user.id,
@@ -286,7 +288,9 @@ export async function PUT(
// 3. 견적 아이템 업데이트
// 기존 아이템 삭제
await tx.delete(rfqLastVendorQuotationItems)
- .where(eq(rfqLastVendorQuotationItems.vendorResponseId, responseId))
+ .where(eq(rfqLastVendorQuotationItems.vendorResponseId, existingResponse.id))
+
+ console.log(data.quotationItems,"data.quotationItems")
// 새 아이템 추가
if (data.quotationItems && data.quotationItems.length > 0) {
diff --git a/app/api/projects/[projectId]/access/route.ts b/app/api/projects/[projectId]/access/route.ts
new file mode 100644
index 00000000..c4b32ca8
--- /dev/null
+++ b/app/api/projects/[projectId]/access/route.ts
@@ -0,0 +1,36 @@
+// app/api/projects/[projectId]/access/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { ProjectService } from '@/lib/services/projectService';
+
+// 프로젝트 접근 권한 확인
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const projectService = new ProjectService();
+ const access = await projectService.checkProjectAccess(
+ params.projectId,
+ Number(session.user.id)
+ );
+
+ return NextResponse.json({
+ hasAccess: access.hasAccess,
+ role: access.role || 'viewer',
+ isOwner: access.isOwner,
+ });
+ } catch (error) {
+ console.error('권한 확인 오류:', error);
+ return NextResponse.json(
+ { hasAccess: false, role: 'viewer', isOwner: false },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/projects/[projectId]/members/[memberId]/route.ts b/app/api/projects/[projectId]/members/[memberId]/route.ts
new file mode 100644
index 00000000..55816661
--- /dev/null
+++ b/app/api/projects/[projectId]/members/[memberId]/route.ts
@@ -0,0 +1,89 @@
+// app/api/projects/[projectId]/members/[memberId]/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { ProjectService } from '@/lib/services/projectService';
+
+// 멤버 역할 수정
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: { projectId: string; memberId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const { role } = await request.json();
+ const projectService = new ProjectService();
+
+ // Owner 또는 Admin만 가능
+ const access = await projectService.checkProjectAccess(
+ params.projectId,
+ session.user.id,
+ 'admin'
+ );
+
+ if (!access.hasAccess && !access.isOwner) {
+ return NextResponse.json(
+ { error: '멤버 역할을 변경할 권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // 멤버 역할 업데이트
+ await projectService.updateMemberRole(
+ params.projectId,
+ params.memberId,
+ role
+ );
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('멤버 역할 변경 오류:', error);
+ return NextResponse.json(
+ { error: '역할 변경에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+// 멤버 제거
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { projectId: string; memberId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const projectService = new ProjectService();
+
+ // Owner만 멤버 제거 가능
+ const isOwner = await projectService.isProjectOwner(
+ params.projectId,
+ session.user.id
+ );
+
+ if (!isOwner) {
+ return NextResponse.json(
+ { error: '멤버를 제거할 권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // 멤버 제거
+ await projectService.removeMember(params.projectId, params.memberId);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('멤버 제거 오류:', error);
+ return NextResponse.json(
+ { error: '멤버 제거에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts
new file mode 100644
index 00000000..d24b61e3
--- /dev/null
+++ b/app/api/projects/[projectId]/members/route.ts
@@ -0,0 +1,76 @@
+// app/api/projects/[projectId]/members/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { ProjectService } from '@/lib/services/projectService';
+
+// 프로젝트 멤버 추가 (Owner만 가능)
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const projectService = new ProjectService();
+
+ await projectService.addProjectMember(
+ params.projectId,
+ body.userId,
+ body.role,
+ Number(session.user.id)
+ );
+
+ return NextResponse.json({ success: true });
+ } catch (error: any) {
+ if (error.message.includes('소유자')) {
+ return NextResponse.json(
+ { error: error.message },
+ { status: 403 }
+ );
+ }
+
+ console.error('멤버 추가 오류:', error);
+ return NextResponse.json(
+ { error: '멤버 추가에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const projectService = new ProjectService();
+
+ const member = await projectService.getProjectMembers(
+ params.projectId,
+ );
+
+ return NextResponse.json({member});
+ } catch (error: any) {
+ if (error.message.includes('소유자')) {
+ return NextResponse.json(
+ { error: error.message },
+ { status: 403 }
+ );
+ }
+
+ console.error('멤버 조회 오류:', error);
+ return NextResponse.json(
+ { error: '멤버 조회에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts
new file mode 100644
index 00000000..38c11930
--- /dev/null
+++ b/app/api/projects/[projectId]/route.ts
@@ -0,0 +1,134 @@
+// app/api/projects/[projectId]/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { ProjectService } from '@/lib/services/projectService';
+import { z } from 'zod';
+
+// GET: 프로젝트 정보 조회
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const projectService = new ProjectService();
+
+ // 프로젝트 접근 권한 확인
+ const access = await projectService.checkProjectAccess(
+ params.projectId,
+ Number(session.user.id)
+ );
+
+ if (!access.hasAccess) {
+ return NextResponse.json(
+ { error: '프로젝트에 접근할 수 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // 프로젝트 정보 가져오기
+ const project = await projectService.getProject(params.projectId);
+
+ if (!project) {
+ return NextResponse.json(
+ { error: '프로젝트를 찾을 수 없습니다' },
+ { status: 404 }
+ );
+ }
+
+ // 사용자의 역할과 함께 프로젝트 정보 반환
+ return NextResponse.json({
+ ...project,
+ role: access.role,
+ isOwner: access.isOwner,
+ });
+ } catch (error) {
+ console.error('프로젝트 조회 오류:', error);
+ return NextResponse.json(
+ { error: '프로젝트 정보를 불러올 수 없습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+// PATCH: 프로젝트 정보 수정
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const projectService = new ProjectService();
+
+ // Admin 이상 권한 확인
+ const access = await projectService.checkProjectAccess(
+ params.projectId,
+ Number(session.user.id),
+ 'admin'
+ );
+
+ if (!access.hasAccess) {
+ return NextResponse.json(
+ { error: '프로젝트를 수정할 권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ await projectService.updateProjectSettings(
+ params.projectId,
+ Number(session.user.id),
+ body
+ );
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('프로젝트 수정 오류:', error);
+ return NextResponse.json(
+ { error: '프로젝트 수정에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+// DELETE: 프로젝트 삭제
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const projectService = new ProjectService();
+
+ // Owner만 삭제 가능
+ await projectService.deleteProject(params.projectId, session.user.id);
+
+ return NextResponse.json({ success: true });
+ } catch (error: any) {
+ if (error.message.includes('소유자')) {
+ return NextResponse.json(
+ { error: error.message },
+ { status: 403 }
+ );
+ }
+
+ console.error('프로젝트 삭제 오류:', error);
+ return NextResponse.json(
+ { error: '프로젝트 삭제에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/projects/[projectId]/stats/route.ts b/app/api/projects/[projectId]/stats/route.ts
new file mode 100644
index 00000000..dc2397ac
--- /dev/null
+++ b/app/api/projects/[projectId]/stats/route.ts
@@ -0,0 +1,275 @@
+// app/api/fileSystemProjects/[projectId]/stats/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import db from "@/db/db";
+import { fileItems, fileActivityLogs, fileSystemProjects, projectMembers } from "@/db/schema";
+import { eq, and, gte, sql, desc } from "drizzle-orm";
+
+export async function GET(
+ request: NextRequest,
+ context: { params: Promise<{ projectId: string }> }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const params = await context.params;
+ const projectId = params.projectId;
+
+ // URL 파라미터에서 날짜 범위 가져오기
+ const searchParams = request.nextUrl.searchParams;
+ const range = searchParams.get('range') || '30d';
+
+ // 날짜 범위 계산
+ const now = new Date();
+ let startDate = new Date();
+
+ switch (range) {
+ case '7d':
+ startDate.setDate(now.getDate() - 7);
+ break;
+ case '30d':
+ startDate.setDate(now.getDate() - 30);
+ break;
+ case '90d':
+ startDate.setDate(now.getDate() - 90);
+ break;
+ default:
+ startDate.setDate(now.getDate() - 30);
+ }
+
+ // 이전 기간 (트렌드 계산용)
+ const previousStartDate = new Date(startDate);
+ previousStartDate.setDate(previousStartDate.getDate() - (now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
+
+ // 프로젝트 접근 권한 확인
+ const projectMember = await db.query.projectMembers.findFirst({
+ where: and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.userId, Number(session.user.id))
+ ),
+ });
+
+ const isInternalUser = session.user.domain !== 'partners';
+
+ // 내부 사용자가 아니고 프로젝트 멤버가 아닌 경우 접근 거부
+ if (!isInternalUser && !projectMember) {
+ return NextResponse.json(
+ { error: '통계를 볼 권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // 1. 스토리지 통계
+ const storageStats = await db
+ .select({
+ totalSize: sql<number>`COALESCE(SUM(${fileItems.size}), 0)`,
+ fileCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'file' THEN 1 END)`,
+ folderCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'folder' THEN 1 END)`,
+ })
+ .from(fileItems)
+ .where(eq(fileItems.projectId, projectId));
+
+ // 카테고리별 파일 수
+ const categoryStats = await db
+ .select({
+ category: fileItems.category,
+ count: sql<number>`COUNT(*)`,
+ })
+ .from(fileItems)
+ .where(and(
+ eq(fileItems.projectId, projectId),
+ eq(fileItems.type, 'file')
+ ))
+ .groupBy(fileItems.category);
+
+ const byCategory = {
+ public: 0,
+ restricted: 0,
+ confidential: 0,
+ internal: 0,
+ };
+
+ categoryStats.forEach(stat => {
+ if (stat.category && stat.category in byCategory) {
+ byCategory[stat.category as keyof typeof byCategory] = Number(stat.count);
+ }
+ });
+
+ // 2. 활동 통계 (현재 기간)
+ const activityStats = await db
+ .select({
+ action: fileActivityLogs.action,
+ count: sql<number>`COUNT(*)`,
+ })
+ .from(fileActivityLogs)
+ .where(and(
+ eq(fileActivityLogs.projectId, projectId),
+ gte(fileActivityLogs.createdAt, startDate)
+ ))
+ .groupBy(fileActivityLogs.action);
+
+ // 이전 기간 통계 (트렌드 계산용)
+ const previousActivityStats = await db
+ .select({
+ action: fileActivityLogs.action,
+ count: sql<number>`COUNT(*)`,
+ })
+ .from(fileActivityLogs)
+ .where(and(
+ eq(fileActivityLogs.projectId, projectId),
+ gte(fileActivityLogs.createdAt, previousStartDate),
+ sql`${fileActivityLogs.createdAt} < ${startDate}`
+ ))
+ .groupBy(fileActivityLogs.action);
+
+ const activityCounts = {
+ views: 0,
+ downloads: 0,
+ uploads: 0,
+ shares: 0,
+ };
+
+ const previousCounts = {
+ downloads: 0,
+ };
+
+ activityStats.forEach(stat => {
+ switch (stat.action) {
+ case 'view':
+ activityCounts.views = Number(stat.count);
+ break;
+ case 'download':
+ activityCounts.downloads = Number(stat.count);
+ break;
+ case 'upload':
+ activityCounts.uploads = Number(stat.count);
+ break;
+ case 'share':
+ activityCounts.shares = Number(stat.count);
+ break;
+ }
+ });
+
+ previousActivityStats.forEach(stat => {
+ if (stat.action === 'download') {
+ previousCounts.downloads = Number(stat.count);
+ }
+ });
+
+ // 트렌드 계산 (다운로드 기준)
+ const trend = previousCounts.downloads > 0
+ ? Math.round(((activityCounts.downloads - previousCounts.downloads) / previousCounts.downloads) * 100)
+ : 0;
+
+ // 3. 사용자 통계
+ const userStats = await db
+ .select({
+ total: sql<number>`COUNT(DISTINCT ${projectMembers.userId})`,
+ })
+ .from(projectMembers)
+ .where(eq(projectMembers.projectId, projectId));
+
+ // 활성 사용자 (최근 활동이 있는 사용자)
+ const activeUsers = await db
+ .select({
+ count: sql<number>`COUNT(DISTINCT ${fileActivityLogs.userId})`,
+ })
+ .from(fileActivityLogs)
+ .where(and(
+ eq(fileActivityLogs.projectId, projectId),
+ gte(fileActivityLogs.createdAt, startDate)
+ ));
+
+ // 역할별 사용자 수 (간단하게 처리)
+ const roleStats = await db
+ .select({
+ role: projectMembers.role,
+ count: sql<number>`COUNT(*)`,
+ })
+ .from(projectMembers)
+ .where(eq(projectMembers.projectId, projectId))
+ .groupBy(projectMembers.role);
+
+ const byRole = {
+ admin: 0,
+ editor: 0,
+ viewer: 0,
+ };
+
+ roleStats.forEach(stat => {
+ if (stat.role === 'manager') byRole.admin = Number(stat.count);
+ else if (stat.role === 'member') byRole.editor = Number(stat.count);
+ else byRole.viewer = Number(stat.count);
+ });
+
+ // 4. 최근 활동 내역
+ const recentActivities = await db
+ .select({
+ action: fileActivityLogs.action,
+ userEmail: fileActivityLogs.userEmail,
+ createdAt: fileActivityLogs.createdAt,
+ fileName: fileItems.name,
+ fileType: fileItems.type,
+ })
+ .from(fileActivityLogs)
+ .leftJoin(fileItems, eq(fileActivityLogs.fileItemId, fileItems.id))
+ .where(and(
+ eq(fileActivityLogs.projectId, projectId),
+ gte(fileActivityLogs.createdAt, startDate)
+ ))
+ .orderBy(desc(fileActivityLogs.createdAt))
+ .limit(10);
+
+ const recent = recentActivities.map(activity => ({
+ type: activity.fileType || 'file',
+ user: activity.userEmail?.split('@')[0] || 'Unknown',
+ action: activity.action,
+ timestamp: activity.createdAt.toISOString(),
+ details: activity.fileName || 'Unknown file',
+ }));
+
+ // 5. 프로젝트 정보 (스토리지 제한 등)
+ const project = await db.query.fileSystemProjects.findFirst({
+ where: eq(fileSystemProjects.id, projectId),
+ });
+
+ const storageLimit = 10 * 1024 * 1024 * 1024; // 기본 10GB
+
+ // 응답 데이터 구성
+ const stats = {
+ storage: {
+ used: Number(storageStats[0]?.totalSize || 0),
+ limit: storageLimit,
+ fileCount: Number(storageStats[0]?.fileCount || 0),
+ folderCount: Number(storageStats[0]?.folderCount || 0),
+ byCategory,
+ },
+ activity: {
+ views: activityCounts.views,
+ downloads: activityCounts.downloads,
+ uploads: activityCounts.uploads,
+ shares: activityCounts.shares,
+ trend,
+ },
+ users: {
+ total: Number(userStats[0]?.total || 0),
+ active: Number(activeUsers[0]?.count || 0),
+ byRole,
+ },
+ recent,
+ };
+
+ return NextResponse.json(stats);
+
+ } catch (error) {
+ console.error('통계 조회 오류:', error);
+ return NextResponse.json(
+ { error: '통계를 불러올 수 없습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts
new file mode 100644
index 00000000..c64676c6
--- /dev/null
+++ b/app/api/projects/route.ts
@@ -0,0 +1,56 @@
+// app/api/projects/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { ProjectService } from '@/lib/services/projectService';
+
+// 프로젝트 생성
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const projectService = new ProjectService();
+
+ const project = await projectService.createProject(
+ {
+ name: body.name,
+ description: body.description,
+ isPublic: body.isPublic || false,
+ },
+ Number(session.user.id)
+ );
+
+ return NextResponse.json(project, { status: 201 });
+ } catch (error) {
+ console.error('프로젝트 생성 오류:', error);
+ return NextResponse.json(
+ { error: '프로젝트 생성에 실패했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
+// 사용자의 프로젝트 목록 조회
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
+ }
+
+ const projectService = new ProjectService();
+ const projects = await projectService.getUserProjects(session.user.id);
+
+ return NextResponse.json(projects);
+ } catch (error) {
+ console.error('프로젝트 목록 조회 오류:', error);
+ return NextResponse.json(
+ { error: '프로젝트 목록을 불러올 수 없습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx
new file mode 100644
index 00000000..483ef773
--- /dev/null
+++ b/components/file-manager/FileManager.tsx
@@ -0,0 +1,1447 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ Folder,
+ File,
+ FolderPlus,
+ Upload,
+ Trash2,
+ Edit2,
+ Download,
+ Share2,
+ Eye,
+ EyeOff,
+ Lock,
+ Unlock,
+ Globe,
+ Shield,
+ AlertCircle,
+ MoreVertical,
+ ChevronRight,
+ ChevronDown,
+ Search,
+ Grid,
+ List,
+ Copy,
+ X
+} from 'lucide-react';
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+ ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+} from '@/components/ui/context-menu';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Badge } from '@/components/ui/badge';
+import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb';
+import { useToast } from '@/hooks/use-toast';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { cn } from '@/lib/utils';
+import { useSession } from 'next-auth/react';
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone";
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list";
+import { decryptWithServerAction } from '@/components/drm/drmUtils';
+import { Progress } from '@/components/ui/progress';
+
+interface FileItem {
+ id: string;
+ name: string;
+ type: 'file' | 'folder';
+ size?: number;
+ mimeType?: string;
+ category: 'public' | 'restricted' | 'confidential' | 'internal';
+ externalAccessLevel?: 'view_only' | 'view_download' | 'full_access';
+ updatedAt: Date;
+ permissions?: {
+ canView: boolean;
+ canDownload: boolean;
+ canEdit: boolean;
+ canDelete: boolean;
+ };
+ downloadCount?: number;
+ viewCount?: number;
+ parentId?: string | null;
+ children?: FileItem[];
+}
+
+interface UploadingFile {
+ file: File;
+ progress: number;
+ status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error';
+ error?: string;
+}
+
+interface FileManagerProps {
+ projectId: string;
+}
+
+// 카테고리별 아이콘과 색상
+const categoryConfig = {
+ public: { icon: Globe, color: 'text-green-500', label: '공개' },
+ restricted: { icon: Eye, color: 'text-yellow-500', label: '제한' },
+ confidential: { icon: Lock, color: 'text-red-500', label: '기밀' },
+ internal: { icon: Shield, color: 'text-blue-500', label: '내부' },
+};
+
+// Tree Item Component
+const TreeItem: React.FC<{
+ item: FileItem;
+ level: number;
+ expandedFolders: Set<string>;
+ selectedItems: Set<string>;
+ onToggleExpand: (id: string) => void;
+ onSelectItem: (id: string) => void;
+ onDoubleClick: (item: FileItem) => void;
+ onDownload: (item: FileItem) => void;
+ onDownloadFolder: (item: FileItem) => void;
+ onDelete: (ids: string[]) => void;
+ onShare: (item: FileItem) => void;
+ onRename: (item: FileItem) => void;
+ isInternalUser: boolean;
+}> = ({
+ item,
+ level,
+ expandedFolders,
+ selectedItems,
+ onToggleExpand,
+ onSelectItem,
+ onDoubleClick,
+ onDownload,
+ onDownloadFolder,
+ onDelete,
+ onShare,
+ onRename,
+ isInternalUser
+}) => {
+ const hasChildren = item.type === 'folder' && item.children && item.children.length > 0;
+ const isExpanded = expandedFolders.has(item.id);
+ const isSelected = selectedItems.has(item.id);
+ const CategoryIcon = categoryConfig[item.category].icon;
+ const categoryColor = categoryConfig[item.category].color;
+ const categoryLabel = categoryConfig[item.category].label;
+
+ const formatFileSize = (bytes?: number) => {
+ if (!bytes) return '-';
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
+ };
+
+ return (
+ <>
+ <div
+ className={cn(
+ "flex items-center p-2 rounded-lg cursor-pointer transition-colors",
+ "hover:bg-accent",
+ isSelected && "bg-accent"
+ )}
+ style={{ paddingLeft: `${level * 24 + 8}px` }}
+ onClick={() => onSelectItem(item.id)}
+ onDoubleClick={() => onDoubleClick(item)}
+ >
+ <div className="flex items-center mr-2">
+ {item.type === 'folder' && (
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onToggleExpand(item.id);
+ }}
+ className="p-0.5 hover:bg-gray-200 rounded"
+ >
+ {isExpanded ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </button>
+ )}
+ {item.type === 'file' && (
+ <div className="w-5" />
+ )}
+ </div>
+
+ {item.type === 'folder' ? (
+ <Folder className="h-5 w-5 text-blue-500 mr-2" />
+ ) : (
+ <File className="h-5 w-5 text-gray-500 mr-2" />
+ )}
+
+ <span className="flex-1">{item.name}</span>
+
+ <Badge variant="outline" className="mr-2">
+ <CategoryIcon className={cn("h-3 w-3 mr-1", categoryColor)} />
+ {categoryLabel}
+ </Badge>
+
+ <span className="text-sm text-muted-foreground mr-4">
+ {formatFileSize(item.size)}
+ </span>
+ <span className="text-sm text-muted-foreground mr-2">
+ {new Date(item.updatedAt).toLocaleDateString()}
+ </span>
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="sm">
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent>
+ {item.type === 'file' && item.permissions?.canDownload && (
+ <DropdownMenuItem onClick={() => onDownload(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </DropdownMenuItem>
+ )}
+
+ {item.type === 'folder' && (
+ <DropdownMenuItem onClick={() => onDownloadFolder(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ 폴더 전체 다운로드
+ </DropdownMenuItem>
+ )}
+
+ {isInternalUser && (
+ <>
+ <DropdownMenuItem onClick={() => onShare(item)}>
+ <Share2 className="h-4 w-4 mr-2" />
+ 공유
+ </DropdownMenuItem>
+
+ {item.permissions?.canEdit && (
+ <DropdownMenuItem onClick={() => onRename(item)}>
+ <Edit2 className="h-4 w-4 mr-2" />
+ 이름 변경
+ </DropdownMenuItem>
+ )}
+ </>
+ )}
+
+ {item.permissions?.canDelete && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ className="text-destructive"
+ onClick={() => onDelete([item.id])}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ 삭제
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {item.type === 'folder' && isExpanded && item.children && (
+ <div>
+ {item.children.map((child) => (
+ <TreeItem
+ key={child.id}
+ item={child}
+ level={level + 1}
+ expandedFolders={expandedFolders}
+ selectedItems={selectedItems}
+ onToggleExpand={onToggleExpand}
+ onSelectItem={onSelectItem}
+ onDoubleClick={onDoubleClick}
+ onDownload={onDownload}
+ onDownloadFolder={onDownloadFolder}
+ onDelete={onDelete}
+ onShare={onShare}
+ onRename={onRename}
+ isInternalUser={isInternalUser}
+ />
+ ))}
+ </div>
+ )}
+ </>
+ );
+};
+
+export function FileManager({ projectId }: FileManagerProps) {
+ const { data: session } = useSession();
+ const [items, setItems] = useState<FileItem[]>([]);
+ const [treeItems, setTreeItems] = useState<FileItem[]>([]);
+ const [currentPath, setCurrentPath] = useState<string[]>([]);
+ const [currentParentId, setCurrentParentId] = useState<string | null>(null);
+ const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ // 업로드 상태
+ const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
+ const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
+ const [uploadCategory, setUploadCategory] = useState<string>('confidential');
+
+ // 다이얼로그 상태
+ const [folderDialogOpen, setFolderDialogOpen] = useState(false);
+ const [shareDialogOpen, setShareDialogOpen] = useState(false);
+ const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
+ const [renameDialogOpen, setRenameDialogOpen] = useState(false);
+
+ // 다이얼로그 데이터
+ const [dialogValue, setDialogValue] = useState('');
+ const [selectedCategory, setSelectedCategory] = useState<string>('confidential');
+ const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
+ const [shareSettings, setShareSettings] = useState({
+ accessLevel: 'view_only',
+ password: '',
+ expiresAt: '',
+ maxDownloads: '',
+ });
+
+ const { toast } = useToast();
+
+ // 사용자가 내부 사용자인지 확인
+ const isInternalUser = session?.user?.domain !== 'partners';
+
+ // 트리 구조 생성 함수
+ const buildTree = (flatItems: FileItem[]): FileItem[] => {
+ const itemMap = new Map<string, FileItem>();
+ const rootItems: FileItem[] = [];
+
+ // 모든 아이템을 맵에 저장 (children 초기화)
+ flatItems.forEach(item => {
+ itemMap.set(item.id, { ...item, children: [] });
+ });
+
+ // 부모-자식 관계 설정
+ flatItems.forEach(item => {
+ const mappedItem = itemMap.get(item.id)!;
+
+ if (!item.parentId) {
+ // parentId가 없으면 루트 아이템
+ rootItems.push(mappedItem);
+ } else {
+ // parentId가 있으면 부모의 children에 추가
+ const parent = itemMap.get(item.parentId);
+ if (parent) {
+ if (!parent.children) parent.children = [];
+ parent.children.push(mappedItem);
+ } else {
+ // 부모를 찾을 수 없으면 루트로 처리
+ rootItems.push(mappedItem);
+ }
+ }
+ });
+
+ return rootItems;
+ };
+
+ // 파일 목록 가져오기
+ const fetchItems = useCallback(async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams();
+
+ // 트리 뷰일 때는 전체 목록을 가져옴
+ if (viewMode === 'list') {
+ params.append('viewMode', 'tree');
+ // 트리 뷰에서도 현재 경로 정보는 유지 (하이라이팅 등에 사용)
+ if (currentParentId) params.append('currentParentId', currentParentId);
+ } else {
+ // 그리드 뷰일 때는 현재 폴더의 내용만 가져옴
+ if (currentParentId) params.append('parentId', currentParentId);
+ }
+
+ const response = await fetch(`/api/data-room/${projectId}?${params}`);
+ if (!response.ok) throw new Error('Failed to fetch files');
+
+ const data = await response.json();
+ setItems(data);
+
+ // 트리 구조 생성
+ if (viewMode === 'list') {
+ const tree = buildTree(data);
+ setTreeItems(tree);
+ }
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '파일을 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ }, [projectId, currentParentId, viewMode, toast]);
+
+ useEffect(() => {
+ fetchItems();
+ }, [fetchItems]);
+
+ // 폴더 생성
+ const createFolder = async () => {
+ try {
+ const response = await fetch(`/api/data-room/${projectId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: dialogValue,
+ type: 'folder',
+ category: selectedCategory,
+ parentId: currentParentId,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to create folder');
+ }
+
+ await fetchItems();
+ setFolderDialogOpen(false);
+ setDialogValue('');
+
+ toast({
+ title: '성공',
+ description: '폴더가 생성되었습니다.',
+ });
+ } catch (error: any) {
+ toast({
+ title: '오류',
+ description: error.message || '폴더 생성에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // 파일 업로드 처리
+ const handleFileUpload = async (files: FileList | File[]) => {
+ const fileArray = Array.from(files);
+
+ // 업로드 파일 목록 초기화
+ const newUploadingFiles: UploadingFile[] = fileArray.map(file => ({
+ file,
+ progress: 0,
+ status: 'pending' as const
+ }));
+
+ setUploadingFiles(newUploadingFiles);
+
+ // 각 파일 업로드 처리
+ for (let i = 0; i < fileArray.length; i++) {
+ const file = fileArray[i];
+
+ try {
+ // 상태 업데이트: 업로드 중
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, status: 'uploading', progress: 20 } : f
+ ));
+
+ // DRM 복호화
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, status: 'processing', progress: 40 } : f
+ ));
+
+ const decryptedData = await decryptWithServerAction(file);
+
+ // FormData 생성
+ const formData = new FormData();
+ const blob = new Blob([decryptedData], { type: file.type });
+ formData.append('file', blob, file.name);
+ formData.append('category', uploadCategory);
+ formData.append('fileSize', file.size.toString()); // 파일 크기 전달
+ if (currentParentId) {
+ formData.append('parentId', currentParentId);
+ }
+
+ // 업로드 진행률 업데이트
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, progress: 60 } : f
+ ));
+
+ // API 호출
+ const response = await fetch(`/api/data-room/${projectId}/upload`, {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Upload failed');
+ }
+
+ // 성공
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, status: 'completed', progress: 100 } : f
+ ));
+
+ } catch (error: any) {
+ // 실패
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? {
+ ...f,
+ status: 'error',
+ error: error.message || '업로드 실패'
+ } : f
+ ));
+ }
+ }
+
+ // 모든 업로드 완료 후 목록 새로고침
+ await fetchItems();
+
+ // 성공한 파일이 있으면 토스트 표시
+ const successCount = newUploadingFiles.filter(f => f.status === 'completed').length;
+ if (successCount > 0) {
+ toast({
+ title: '업로드 완료',
+ description: `${successCount}개 파일이 업로드되었습니다.`,
+ });
+ }
+ };
+
+ // 폴더 다운로드
+ const downloadFolder = async (folder: FileItem) => {
+ if (folder.type !== 'folder') return;
+
+ try {
+ toast({
+ title: '권한 확인 중',
+ description: '폴더 내 파일들의 다운로드 권한을 확인하고 있습니다...',
+ });
+
+ // 폴더 다운로드 API 호출
+ const response = await fetch(`/api/data-room/${projectId}/download-folder/${folder.id}`, {
+ method: 'GET',
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+
+ // 권한이 없는 파일이 있는 경우 상세 정보 제공
+ if (error.unauthorizedFiles) {
+ toast({
+ title: '다운로드 권한 부족',
+ description: `${error.unauthorizedFiles.length}개 파일에 대한 권한이 없습니다: ${error.unauthorizedFiles.join(', ')}`,
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ throw new Error(error.error || '폴더 다운로드 실패');
+ }
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+
+ // 폴더명을 파일명에 포함
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const fileName = `${folder.name}_${timestamp}.zip`;
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast({
+ title: '다운로드 완료',
+ description: `${folder.name} 폴더가 다운로드되었습니다.`,
+ });
+
+ } catch (error: any) {
+ toast({
+ title: '오류',
+ description: error.message || '폴더 다운로드에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // 파일 공유
+ const shareFile = async () => {
+ if (!selectedFile) return;
+
+ try {
+ const response = await fetch(`/api/data-room/${projectId}/share`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ fileId: selectedFile.id,
+ ...shareSettings,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to create share link');
+ }
+
+ const data = await response.json();
+
+ // 공유 링크 복사
+ await navigator.clipboard.writeText(data.shareUrl);
+
+ toast({
+ title: '공유 링크 생성됨',
+ description: '링크가 클립보드에 복사되었습니다.',
+ });
+
+ setShareDialogOpen(false);
+ setSelectedFile(null);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '공유 링크 생성에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // 다중 파일 다운로드
+ const downloadMultipleFiles = async (itemIds: string[]) => {
+ // 선택된 파일들 중 실제 파일만 필터링 (폴더 제외)
+ const filesToDownload = items.filter(item =>
+ itemIds.includes(item.id) &&
+ item.type === 'file' &&
+ item.permissions?.canDownload
+ );
+
+ if (filesToDownload.length === 0) {
+ toast({
+ title: '알림',
+ description: '다운로드 가능한 파일이 없습니다.',
+ variant: 'default',
+ });
+ return;
+ }
+
+ // 단일 파일인 경우 일반 다운로드 사용
+ if (filesToDownload.length === 1) {
+ await downloadFile(filesToDownload[0]);
+ return;
+ }
+
+ try {
+ toast({
+ title: '다운로드 준비 중',
+ description: `${filesToDownload.length}개 파일을 압축하고 있습니다...`,
+ });
+
+ // 여러 파일 다운로드 API 호출
+ const response = await fetch(`/api/data-room/${projectId}/download-multiple`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ fileIds: filesToDownload.map(f => f.id) })
+ });
+
+ if (!response.ok) {
+ throw new Error('다운로드 실패');
+ }
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+
+ // 현재 날짜시간을 파일명에 포함
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const fileName = `files_${timestamp}.zip`;
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast({
+ title: '다운로드 완료',
+ description: `${filesToDownload.length}개 파일이 다운로드되었습니다.`,
+ });
+
+ } catch (error) {
+ console.error('다중 다운로드 오류:', error);
+
+ // 실패 시 개별 다운로드 옵션 제공
+ toast({
+ title: '압축 다운로드 실패',
+ description: '개별 다운로드를 시도하시겠습니까?',
+ action: (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => {
+ // 개별 다운로드 실행
+ filesToDownload.forEach(async (file, index) => {
+ // 다운로드 간격을 두어 브라우저 부하 감소
+ setTimeout(() => downloadFile(file), index * 500);
+ });
+ }}
+ >
+ 개별 다운로드
+ </Button>
+ ),
+ });
+ }
+ };
+
+ // 파일 다운로드
+ const downloadFile = async (file: FileItem) => {
+ try {
+ const response = await fetch(`/api/data-room/${projectId}/${file.id}/download`);
+
+ if (!response.ok) {
+ throw new Error('Download failed');
+ }
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = file.name;
+ document.body.appendChild(a);
+ a.click();
+
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '다운로드에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // 파일 삭제
+ const deleteItems = async (itemIds: string[]) => {
+ try {
+ await Promise.all(
+ itemIds.map(id =>
+ fetch(`/api/data-room/${projectId}/${id}`, { method: 'DELETE' })
+ )
+ );
+
+ await fetchItems();
+ setSelectedItems(new Set());
+
+ toast({
+ title: '성공',
+ description: '선택한 항목이 삭제되었습니다.',
+ });
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '삭제에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // 폴더 더블클릭 처리
+ const handleFolderOpen = (folder: FileItem) => {
+ if (viewMode === 'grid') {
+ setCurrentPath([...currentPath, folder.name]);
+ setCurrentParentId(folder.id);
+ } else {
+ // 트리 뷰에서는 expand/collapse
+ const newExpanded = new Set(expandedFolders);
+ if (newExpanded.has(folder.id)) {
+ newExpanded.delete(folder.id);
+ } else {
+ newExpanded.add(folder.id);
+ }
+ setExpandedFolders(newExpanded);
+ }
+ setSelectedItems(new Set());
+ };
+
+ // 폴더 확장 토글
+ const toggleFolderExpand = (folderId: string) => {
+ const newExpanded = new Set(expandedFolders);
+ if (newExpanded.has(folderId)) {
+ newExpanded.delete(folderId);
+ } else {
+ newExpanded.add(folderId);
+ }
+ setExpandedFolders(newExpanded);
+ };
+
+ // 아이템 선택
+ const toggleItemSelection = (itemId: string) => {
+ const newSelected = new Set(selectedItems);
+ if (newSelected.has(itemId)) {
+ newSelected.delete(itemId);
+ } else {
+ newSelected.add(itemId);
+ }
+ setSelectedItems(newSelected);
+ };
+
+ // 경로 탐색
+ const navigateToPath = (index: number) => {
+ if (index === -1) {
+ setCurrentPath([]);
+ setCurrentParentId(null);
+ } else {
+ setCurrentPath(currentPath.slice(0, index + 1));
+ // parentId 업데이트 로직 필요
+ }
+ };
+
+ // 필터링된 아이템
+ const filteredItems = items.filter(item =>
+ item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ const filteredTreeItems = treeItems.filter(item =>
+ item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ // 파일 크기 포맷
+ const formatFileSize = (bytes?: number) => {
+ if (!bytes) return '-';
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ {/* 툴바 */}
+ <div className="border-b p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ {isInternalUser && (
+ <>
+ <Button
+ size="sm"
+ onClick={() => setFolderDialogOpen(true)}
+ >
+ <FolderPlus className="h-4 w-4 mr-1" />
+ 새 폴더
+ </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => setUploadDialogOpen(true)}
+ >
+ <Upload className="h-4 w-4 mr-1" />
+ 업로드
+ </Button>
+ </>
+ )}
+
+ {selectedItems.size > 0 && (
+ <>
+ {/* 다중 다운로드 버튼 */}
+ {items.filter(item =>
+ selectedItems.has(item.id) &&
+ item.type === 'file' &&
+ item.permissions?.canDownload
+ ).length > 0 && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => downloadMultipleFiles(Array.from(selectedItems))}
+ >
+ <Download className="h-4 w-4 mr-1" />
+ 다운로드 ({items.filter(item =>
+ selectedItems.has(item.id) && item.type === 'file'
+ ).length})
+ </Button>
+ )}
+
+ {/* 삭제 버튼 */}
+ {items.find(item => selectedItems.has(item.id))?.permissions?.canDelete && (
+ <Button
+ size="sm"
+ variant="destructive"
+ onClick={() => deleteItems(Array.from(selectedItems))}
+ >
+ <Trash2 className="h-4 w-4 mr-1" />
+ 삭제 ({selectedItems.size})
+ </Button>
+ )}
+ </>
+ )}
+
+ {!isInternalUser && (
+ <Badge variant="secondary" className="ml-2">
+ <Shield className="h-3 w-3 mr-1" />
+ 외부 사용자
+ </Badge>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2">
+ <div className="relative">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="검색..."
+ className="pl-8 w-64"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
+ >
+ {viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />}
+ </Button>
+ </div>
+ </div>
+
+ {/* Breadcrumb */}
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink onClick={() => navigateToPath(-1)}>
+ Home
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ {currentPath.map((path, index) => (
+ <BreadcrumbItem key={index}>
+ <ChevronRight className="h-4 w-4" />
+ <BreadcrumbLink onClick={() => navigateToPath(index)}>
+ {path}
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ ))}
+ </BreadcrumbList>
+ </Breadcrumb>
+ </div>
+
+ {/* 파일 목록 */}
+ <ScrollArea className="flex-1 p-4">
+ {loading ? (
+ <div className="flex justify-center items-center h-64">
+ <div className="text-muted-foreground">로딩 중...</div>
+ </div>
+ ) : filteredItems.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-64">
+ <Folder className="h-12 w-12 text-muted-foreground mb-2" />
+ <p className="text-muted-foreground">비어있음</p>
+ </div>
+ ) : viewMode === 'grid' ? (
+ <div className="grid grid-cols-6 gap-4">
+ {filteredItems.map((item) => {
+ const CategoryIcon = categoryConfig[item.category].icon;
+ const categoryColor = categoryConfig[item.category].color;
+
+ return (
+ <ContextMenu key={item.id}>
+ <ContextMenuTrigger>
+ <div
+ className={cn(
+ "flex flex-col items-center p-3 rounded-lg cursor-pointer transition-colors",
+ "hover:bg-accent",
+ selectedItems.has(item.id) && "bg-accent"
+ )}
+ onClick={() => toggleItemSelection(item.id)}
+ onDoubleClick={() => {
+ if (item.type === 'folder') {
+ handleFolderOpen(item);
+ }
+ }}
+ >
+ <div className="relative">
+ {item.type === 'folder' ? (
+ <Folder className="h-12 w-12 text-blue-500" />
+ ) : (
+ <File className="h-12 w-12 text-gray-500" />
+ )}
+ <CategoryIcon className={cn("h-4 w-4 absolute -bottom-1 -right-1", categoryColor)} />
+ </div>
+
+ <span className="mt-2 text-sm text-center truncate w-full">
+ {item.name}
+ </span>
+
+ {item.viewCount !== undefined && (
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Eye className="h-3 w-3 mr-1" />
+ {item.viewCount}
+ </span>
+ {item.downloadCount !== undefined && (
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Download className="h-3 w-3 mr-1" />
+ {item.downloadCount}
+ </span>
+ )}
+ </div>
+ )}
+ </div>
+ </ContextMenuTrigger>
+
+ <ContextMenuContent>
+ {item.type === 'folder' && (
+ <>
+ <ContextMenuItem onClick={() => handleFolderOpen(item)}>
+ 열기
+ </ContextMenuItem>
+ <ContextMenuItem onClick={() => downloadFolder(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ 폴더 전체 다운로드
+ </ContextMenuItem>
+ </>
+ )}
+
+ {item.type === 'file' && item.permissions?.canDownload && (
+ <ContextMenuItem onClick={() => downloadFile(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </ContextMenuItem>
+ )}
+
+ {isInternalUser && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuSub>
+ <ContextMenuSubTrigger>
+ <Shield className="h-4 w-4 mr-2" />
+ 카테고리 변경
+ </ContextMenuSubTrigger>
+ <ContextMenuSubContent>
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <ContextMenuItem key={key}>
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ {config.label}
+ </ContextMenuItem>
+ ))}
+ </ContextMenuSubContent>
+ </ContextMenuSub>
+
+ <ContextMenuItem
+ onClick={() => {
+ setSelectedFile(item);
+ setShareDialogOpen(true);
+ }}
+ >
+ <Share2 className="h-4 w-4 mr-2" />
+ 공유
+ </ContextMenuItem>
+
+ {item.permissions?.canEdit && (
+ <ContextMenuItem onClick={() => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}>
+ <Edit2 className="h-4 w-4 mr-2" />
+ 이름 변경
+ </ContextMenuItem>
+ )}
+ </>
+ )}
+
+ {item.permissions?.canDelete && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ className="text-destructive"
+ onClick={() => deleteItems([item.id])}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ 삭제
+ </ContextMenuItem>
+ </>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+ })}
+ </div>
+ ) : (
+ // Tree View
+ <div className="space-y-1">
+ {filteredTreeItems.map((item) => (
+ <TreeItem
+ key={item.id}
+ item={item}
+ level={0}
+ expandedFolders={expandedFolders}
+ selectedItems={selectedItems}
+ onToggleExpand={toggleFolderExpand}
+ onSelectItem={toggleItemSelection}
+ onDoubleClick={handleFolderOpen}
+ onDownload={downloadFile}
+ onDownloadFolder={downloadFolder}
+ onDelete={deleteItems}
+ onShare={(item) => {
+ setSelectedFile(item);
+ setShareDialogOpen(true);
+ }}
+ onRename={(item) => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}
+ isInternalUser={isInternalUser}
+ />
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 업로드 다이얼로그 */}
+ <Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>파일 업로드</DialogTitle>
+ <DialogDescription>
+ 파일을 드래그 앤 드롭하거나 클릭하여 선택하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 카테고리 선택 */}
+ <div>
+ <Label htmlFor="upload-category">카테고리</Label>
+ <Select value={uploadCategory} onValueChange={setUploadCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* Dropzone */}
+ <Dropzone
+ onDrop={(acceptedFiles: File[]) => {
+ handleFileUpload(acceptedFiles);
+ }}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/vnd.ms-powerpoint': ['.ppt'],
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
+ 'text/plain': ['.txt'],
+ 'text/csv': ['.csv'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
+ 'application/zip': ['.zip'],
+ 'application/x-rar-compressed': ['.rar'],
+ 'application/x-7z-compressed': ['.7z'],
+ 'application/x-dwg': ['.dwg'],
+ 'application/x-dxf': ['.dxf'],
+ }}
+ multiple={true}
+ disabled={false}
+ >
+ <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg">
+ <DropzoneInput />
+ <div className="flex flex-col items-center justify-center h-full">
+ <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
+ <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle>
+ <DropzoneDescription>여러 파일을 동시에 업로드할 수 있습니다</DropzoneDescription>
+ </div>
+ </DropzoneZone>
+ </Dropzone>
+
+ {/* 업로드 중인 파일 목록 */}
+ {uploadingFiles.length > 0 && (
+ <FileList>
+ <FileListHeader>업로드 중인 파일</FileListHeader>
+ {uploadingFiles.map((uploadFile, index) => (
+ <FileListItem key={index}>
+ <FileListIcon>
+ <File className="h-4 w-4" />
+ </FileListIcon>
+ <FileListInfo>
+ <FileListName>{uploadFile.file.name}</FileListName>
+ <FileListDescription>
+ <div className="flex items-center gap-2">
+ <FileListSize>{uploadFile.file.size}</FileListSize>
+ {uploadFile.status === 'uploading' && <span>업로드 중...</span>}
+ {uploadFile.status === 'processing' && <span>처리 중...</span>}
+ {uploadFile.status === 'completed' && (
+ <span className="text-green-600">완료</span>
+ )}
+ {uploadFile.status === 'error' && (
+ <span className="text-red-600">{uploadFile.error}</span>
+ )}
+ </div>
+ {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && (
+ <Progress value={uploadFile.progress} className="h-1 mt-1" />
+ )}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction>
+ {uploadFile.status === 'error' && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => {
+ setUploadingFiles(prev =>
+ prev.filter((_, i) => i !== index)
+ );
+ }}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setUploadDialogOpen(false);
+ setUploadingFiles([]);
+ }}
+ >
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 폴더 생성 다이얼로그 */}
+ <Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>새 폴더 만들기</DialogTitle>
+ <DialogDescription>
+ 폴더 이름과 접근 권한 카테고리를 설정하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="folder-name">폴더 이름</Label>
+ <Input
+ id="folder-name"
+ value={dialogValue}
+ onChange={(e) => setDialogValue(e.target.value)}
+ placeholder="폴더 이름 입력"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="folder-category">카테고리</Label>
+ <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setFolderDialogOpen(false)}>
+ 취소
+ </Button>
+ <Button onClick={createFolder}>생성</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 파일 공유 다이얼로그 */}
+ <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>파일 공유</DialogTitle>
+ <DialogDescription>
+ {selectedFile?.name}을(를) 공유합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="link" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="link">링크 공유</TabsTrigger>
+ <TabsTrigger value="permission">권한 설정</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="link" className="space-y-4">
+ <div>
+ <Label htmlFor="access-level">접근 레벨</Label>
+ <Select
+ value={shareSettings.accessLevel}
+ onValueChange={(value) => setShareSettings({...shareSettings, accessLevel: value})}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="view_only">
+ <div className="flex items-center">
+ <Eye className="h-4 w-4 mr-2" />
+ 보기만 가능
+ </div>
+ </SelectItem>
+ <SelectItem value="view_download">
+ <div className="flex items-center">
+ <Download className="h-4 w-4 mr-2" />
+ 보기 + 다운로드
+ </div>
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label htmlFor="password">비밀번호 (선택)</Label>
+ <Input
+ id="password"
+ type="password"
+ value={shareSettings.password}
+ onChange={(e) => setShareSettings({...shareSettings, password: e.target.value})}
+ placeholder="비밀번호 입력"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="expires">만료일 (선택)</Label>
+ <Input
+ id="expires"
+ type="datetime-local"
+ value={shareSettings.expiresAt}
+ onChange={(e) => setShareSettings({...shareSettings, expiresAt: e.target.value})}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="max-downloads">최대 다운로드 횟수 (선택)</Label>
+ <Input
+ id="max-downloads"
+ type="number"
+ value={shareSettings.maxDownloads}
+ onChange={(e) => setShareSettings({...shareSettings, maxDownloads: e.target.value})}
+ placeholder="무제한"
+ />
+ </div>
+ </TabsContent>
+
+ <TabsContent value="permission" className="space-y-4">
+ <div>
+ <Label htmlFor="target-domain">대상 도메인</Label>
+ <Select>
+ <SelectTrigger>
+ <SelectValue placeholder="도메인 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="partners">파트너</SelectItem>
+ <SelectItem value="internal">내부</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>권한</Label>
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-view" className="text-sm font-normal">보기</Label>
+ <Switch id="can-view" defaultChecked />
+ </div>
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-download" className="text-sm font-normal">다운로드</Label>
+ <Switch id="can-download" />
+ </div>
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-edit" className="text-sm font-normal">수정</Label>
+ <Switch id="can-edit" />
+ </div>
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-share" className="text-sm font-normal">공유</Label>
+ <Switch id="can-share" />
+ </div>
+ </div>
+ </div>
+ </TabsContent>
+ </Tabs>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setShareDialogOpen(false)}>
+ 취소
+ </Button>
+ <Button onClick={shareFile}>
+ <Share2 className="h-4 w-4 mr-2" />
+ 공유 링크 생성
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/components/file-manager/SharedFileViewer.tsx b/components/file-manager/SharedFileViewer.tsx
new file mode 100644
index 00000000..a6e4eef5
--- /dev/null
+++ b/components/file-manager/SharedFileViewer.tsx
@@ -0,0 +1,411 @@
+// components/file-manager/SharedFileViewer.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+ Download,
+ Eye,
+ EyeOff,
+ FileText,
+ Image,
+ Film,
+ Music,
+ Archive,
+ Code,
+ File,
+ Lock,
+ AlertCircle,
+ Calendar,
+ Clock,
+ User
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+
+interface SharedFile {
+ id: string;
+ name: string;
+ type: 'file' | 'folder';
+ size: number;
+ mimeType?: string;
+ category: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface SharedFileViewerProps {
+ token: string;
+}
+
+export function SharedFileViewer({ token }: SharedFileViewerProps) {
+ const [file, setFile] = useState<SharedFile | null>(null);
+ const [accessLevel, setAccessLevel] = useState<string>('');
+ const [passwordRequired, setPasswordRequired] = useState(false);
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const [showContent, setShowContent] = useState(false);
+ const [downloading, setDownloading] = useState(false);
+
+ const { toast } = useToast();
+
+ useEffect(() => {
+ // 초기 접근 시도
+ checkAccess();
+ }, [token]);
+
+ const checkAccess = async (pwd?: string) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const params = new URLSearchParams();
+ if (pwd) params.append('password', pwd);
+
+ const response = await fetch(`/api/shared/${token}?${params}`);
+ const data = await response.json();
+
+ if (!response.ok) {
+ if (data.error?.includes('비밀번호')) {
+ setPasswordRequired(true);
+ setError('비밀번호가 필요합니다');
+ } else if (data.error?.includes('만료')) {
+ setError('이 공유 링크는 만료되었습니다');
+ } else if (data.error?.includes('최대 다운로드')) {
+ setError('최대 다운로드 횟수를 초과했습니다');
+ } else {
+ setError(data.error || '파일에 접근할 수 없습니다');
+ }
+ return;
+ }
+
+ setFile(data.file);
+ setAccessLevel(data.accessLevel);
+ setShowContent(true);
+ setPasswordRequired(false);
+ } catch (err) {
+ setError('파일을 불러오는 중 오류가 발생했습니다');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handlePasswordSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ checkAccess(password);
+ };
+
+ const handleDownload = async () => {
+ if (!file || accessLevel !== 'view_download') return;
+
+ setDownloading(true);
+ try {
+ const response = await fetch(`/api/shared/${token}/download`, {
+ method: 'POST',
+ headers: password ? { 'X-Share-Password': password } : {},
+ });
+
+ if (!response.ok) {
+ throw new Error('다운로드 실패');
+ }
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = file.name;
+ document.body.appendChild(a);
+ a.click();
+
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast({
+ title: '다운로드 완료',
+ description: `${file.name} 파일이 다운로드되었습니다.`,
+ });
+ } catch (error) {
+ toast({
+ title: '다운로드 실패',
+ description: '파일 다운로드 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setDownloading(false);
+ }
+ };
+
+ const getFileIcon = (mimeType?: string, name?: string) => {
+ if (!mimeType && name) {
+ const ext = name.split('.').pop()?.toLowerCase();
+ if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) return Image;
+ if (['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) return Film;
+ if (['mp3', 'wav', 'flac'].includes(ext || '')) return Music;
+ if (['zip', 'rar', '7z', 'tar'].includes(ext || '')) return Archive;
+ if (['js', 'ts', 'py', 'java', 'cpp'].includes(ext || '')) return Code;
+ if (['pdf', 'doc', 'docx', 'txt'].includes(ext || '')) return FileText;
+ }
+
+ if (mimeType?.startsWith('image/')) return Image;
+ if (mimeType?.startsWith('video/')) return Film;
+ if (mimeType?.startsWith('audio/')) return Music;
+ if (mimeType?.includes('zip') || mimeType?.includes('compressed')) return Archive;
+ if (mimeType?.includes('pdf')) return FileText;
+
+ return File;
+ };
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ if (loading) {
+ return (
+ <div className="min-h-screen flex items-center justify-center">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4" />
+ <p className="text-muted-foreground">파일 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ );
+ }
+
+ if (error && !passwordRequired) {
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4">
+ <Card className="max-w-md w-full">
+ <CardHeader>
+ <div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
+ <AlertCircle className="h-6 w-6 text-red-600" />
+ </div>
+ <CardTitle className="text-center">접근할 수 없습니다</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Alert variant="destructive">
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ if (passwordRequired && !showContent) {
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4">
+ <Card className="max-w-md w-full">
+ <CardHeader>
+ <div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
+ <Lock className="h-6 w-6 text-blue-600" />
+ </div>
+ <CardTitle className="text-center">비밀번호 입력</CardTitle>
+ <CardDescription className="text-center">
+ 이 파일은 비밀번호로 보호되어 있습니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <form onSubmit={handlePasswordSubmit} className="space-y-4">
+ <div>
+ <Label htmlFor="password">비밀번호</Label>
+ <Input
+ id="password"
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ placeholder="비밀번호를 입력하세요"
+ autoFocus
+ />
+ </div>
+ {error && (
+ <Alert variant="destructive">
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ )}
+ <Button type="submit" className="w-full">
+ <Lock className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </form>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ if (!file) return null;
+
+ const FileIcon = getFileIcon(file.mimeType, file.name);
+
+ return (
+ <div className="min-h-screen bg-gray-50">
+ {/* 헤더 */}
+ <div className="bg-white border-b">
+ <div className="container mx-auto px-4 py-4">
+ <div className="flex items-center gap-3">
+ <div className="h-10 w-10 bg-blue-600 rounded-lg flex items-center justify-center">
+ <span className="text-white font-bold">FM</span>
+ </div>
+ <div>
+ <h1 className="text-lg font-semibold">공유된 파일</h1>
+ <p className="text-sm text-muted-foreground">File Manager Shared</p>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 메인 컨텐츠 */}
+ <div className="container mx-auto px-4 py-8 max-w-4xl">
+ <Card>
+ <CardHeader>
+ <div className="flex items-start justify-between">
+ <div className="flex items-start gap-4">
+ <div className={cn(
+ "h-16 w-16 rounded-lg flex items-center justify-center",
+ "bg-gradient-to-br from-blue-50 to-blue-100"
+ )}>
+ <FileIcon className="h-8 w-8 text-blue-600" />
+ </div>
+ <div>
+ <CardTitle className="text-2xl">{file.name}</CardTitle>
+ <CardDescription className="mt-1">
+ {file.type === 'folder' ? '폴더' : formatFileSize(file.size)}
+ </CardDescription>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {accessLevel === 'view_only' && (
+ <Badge variant="secondary">
+ <Eye className="h-3 w-3 mr-1" />
+ 보기 전용
+ </Badge>
+ )}
+ {accessLevel === 'view_download' && (
+ <Badge variant="default">
+ <Download className="h-3 w-3 mr-1" />
+ 다운로드 가능
+ </Badge>
+ )}
+ </div>
+ </div>
+ </CardHeader>
+
+ <CardContent>
+ <div className="space-y-6">
+ {/* 파일 정보 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-3">
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">파일 유형</p>
+ <p className="font-medium">{file.mimeType || '알 수 없음'}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">생성일</p>
+ <p className="font-medium flex items-center gap-1">
+ <Calendar className="h-4 w-4" />
+ {new Date(file.createdAt).toLocaleDateString()}
+ </p>
+ </div>
+ </div>
+
+ <div className="space-y-3">
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">카테고리</p>
+ <Badge variant="outline">{file.category}</Badge>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">수정일</p>
+ <p className="font-medium flex items-center gap-1">
+ <Clock className="h-4 w-4" />
+ {new Date(file.updatedAt).toLocaleDateString()}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 미리보기 영역 (이미지인 경우) */}
+ {file.mimeType?.startsWith('image/') && accessLevel !== 'download_only' && (
+ <div className="bg-gray-50 rounded-lg p-4">
+ <p className="text-sm text-muted-foreground mb-3">미리보기</p>
+ <div className="bg-white rounded border p-4">
+ <img
+ src={`/api/shared/${token}/preview`}
+ alt={file.name}
+ className="max-w-full h-auto rounded"
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 액션 버튼 */}
+ <div className="flex gap-3">
+ {accessLevel === 'view_download' && (
+ <Button
+ onClick={handleDownload}
+ disabled={downloading}
+ className="flex-1"
+ >
+ {downloading ? (
+ <>
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
+ 다운로드 중...
+ </>
+ ) : (
+ <>
+ <Download className="h-4 w-4 mr-2" />
+ 파일 다운로드
+ </>
+ )}
+ </Button>
+ )}
+
+ {accessLevel === 'view_only' && (
+ <Alert className="flex-1">
+ <Eye className="h-4 w-4" />
+ <AlertDescription>
+ 이 파일은 보기 전용입니다. 다운로드할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+
+ {/* 보안 안내 */}
+ <Alert>
+ <Lock className="h-4 w-4" />
+ <AlertDescription>
+ 이 링크는 보안을 위해 제한된 시간 동안만 유효합니다.
+ 필요한 경우 파일을 다운로드하여 보관하세요.
+ </AlertDescription>
+ </Alert>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 하단 정보 */}
+ <div className="mt-6 text-center text-sm text-muted-foreground">
+ <p>© 2024 File Manager. All rights reserved.</p>
+ <p className="mt-1">
+ 문제가 있으신가요?{' '}
+ <a href="/support" className="text-primary hover:underline">
+ 고객 지원
+ </a>
+ </p>
+ </div>
+ </div>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/components/project/ProjectDashboard.tsx b/components/project/ProjectDashboard.tsx
new file mode 100644
index 00000000..d9ec2e0c
--- /dev/null
+++ b/components/project/ProjectDashboard.tsx
@@ -0,0 +1,476 @@
+// components/project/ProjectDashboard.tsx
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import {
+ Crown,
+ Users,
+ Settings,
+ FolderOpen,
+ Shield,
+ UserPlus,
+ Trash2,
+ BarChart3,
+ Eye,
+ Download,
+ HardDrive,
+ UserCog,
+ Loader2
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { useSession } from 'next-auth/react';
+
+interface ProjectDashboardProps {
+ projectId: string;
+}
+
+interface ProjectStats {
+ files: {
+ totalFiles: number;
+ totalSize: number;
+ publicFiles: number;
+ restrictedFiles: number;
+ confidentialFiles: number;
+ };
+ members: {
+ totalMembers: number;
+ admins: number;
+ editors: number;
+ viewers: number;
+ };
+ activity: {
+ views: number;
+ downloads: number;
+ uploads: number;
+ uniqueUsers: number;
+ };
+}
+
+export function ProjectDashboard({ projectId }: ProjectDashboardProps) {
+ const { data: session } = useSession();
+ const [isOwner, setIsOwner] = useState(false);
+ const [projectRole, setProjectRole] = useState<string>('viewer');
+ const [stats, setStats] = useState<ProjectStats | null>(null);
+ const [members, setMembers] = useState<any[]>([]);
+ const [loading, setLoading] = useState(true);
+
+ console.log(stats)
+
+ // 다이얼로그 상태
+ const [addMemberOpen, setAddMemberOpen] = useState(false);
+ const [transferOwnershipOpen, setTransferOwnershipOpen] = useState(false);
+ const [newMemberEmail, setNewMemberEmail] = useState('');
+ const [newMemberRole, setNewMemberRole] = useState('viewer');
+ const [newOwnerId, setNewOwnerId] = useState('');
+
+ const { toast } = useToast();
+
+ // 프로젝트 정보 및 권한 확인
+ useEffect(() => {
+ const fetchProjectData = async () => {
+ try {
+ // 권한 확인
+ const accessRes = await fetch(`/api/projects/${projectId}/access`);
+ const accessData = await accessRes.json();
+ setIsOwner(accessData.isOwner);
+ setProjectRole(accessData.role);
+
+ // Owner인 경우 통계 가져오기
+ if (accessData.isOwner) {
+ const statsRes = await fetch(`/api/projects/${projectId}/stats`);
+ const statsData = await statsRes.json();
+ setStats(statsData);
+ }
+
+ // 멤버 목록 가져오기
+ const membersRes = await fetch(`/api/projects/${projectId}/members`);
+ const membersData = await membersRes.json();
+ setMembers(membersData.member);
+
+ } catch (error) {
+ console.error('프로젝트 데이터 로드 실패:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchProjectData();
+ }, [projectId]);
+
+ // 멤버 추가
+ const handleAddMember = async () => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: newMemberEmail,
+ role: newMemberRole,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('멤버 추가 실패');
+ }
+
+ toast({
+ title: '성공',
+ description: '새 멤버가 추가되었습니다.',
+ });
+
+ setAddMemberOpen(false);
+ // 멤버 목록 새로고침
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 추가에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // 소유권 이전
+ const handleTransferOwnership = async () => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/transfer-ownership`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ newOwnerId: newOwnerId,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('소유권 이전 실패');
+ }
+
+ toast({
+ title: '성공',
+ description: '프로젝트 소유권이 이전되었습니다.',
+ });
+
+ setTransferOwnershipOpen(false);
+ setIsOwner(false);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '소유권 이전에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const formatBytes = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ const roleConfig = {
+ owner: { label: 'Owner', icon: Crown, color: 'text-yellow-500' },
+ admin: { label: 'Admin', icon: Shield, color: 'text-blue-500' },
+ editor: { label: 'Editor', icon: FolderOpen, color: 'text-green-500' },
+ viewer: { label: 'Viewer', icon: Eye, color: 'text-gray-500' },
+ };
+
+ if (loading) {
+ return (
+ <div className="flex items-center justify-center min-h-[400px]">
+ <div className="text-center space-y-3">
+ <Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
+ <p className="text-sm text-muted-foreground">프로젝트 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <h1 className="text-2xl font-bold">프로젝트 대시보드</h1>
+ <Badge variant="outline" className="flex items-center gap-1">
+ {roleConfig[projectRole as keyof typeof roleConfig].icon &&
+ React.createElement(roleConfig[projectRole as keyof typeof roleConfig].icon, {
+ className: `h-3 w-3 ${roleConfig[projectRole as keyof typeof roleConfig].color}`
+ })
+ }
+ {roleConfig[projectRole as keyof typeof roleConfig].label}
+ </Badge>
+ </div>
+
+ {isOwner && (
+ <div className="flex gap-2">
+ <Button onClick={() => setAddMemberOpen(true)}>
+ <UserPlus className="h-4 w-4 mr-2" />
+ 멤버 추가
+ </Button>
+ <Button variant="outline">
+ <Settings className="h-4 w-4 mr-2" />
+ 설정
+ </Button>
+ </div>
+ )}
+ </div>
+
+ {/* Owner 전용 통계 */}
+ {isOwner && stats && (
+ <div className="grid grid-cols-4 gap-4">
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">총 파일 수</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.storage.fileCount}</div>
+ <p className="text-xs text-muted-foreground mt-1">
+ {formatBytes(stats.storage.used)}
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">멤버</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.users.total}</div>
+ <div className="flex gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ 관리자 {stats.users.byRole.admins}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ 편집자 {stats.users.byRole.editors}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">조회수 (30일)</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.activity.views}</div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 활성 사용자 {stats.users.active}명
+ </p>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">다운로드 (30일)</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{stats.activity.downloads}</div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 업로드 {stats.activity.uploads}개
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* 탭 컨텐츠 */}
+ <Tabs defaultValue="members">
+ <TabsList>
+ <TabsTrigger value="members">멤버</TabsTrigger>
+ {isOwner && (
+ <>
+ <TabsTrigger value="permissions">권한 관리</TabsTrigger>
+ <TabsTrigger value="danger">위험 영역</TabsTrigger>
+ </>
+ )}
+ </TabsList>
+
+ <TabsContent value="members" className="mt-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>프로젝트 멤버</CardTitle>
+ <CardDescription>
+ 이 프로젝트에 접근할 수 있는 사용자 목록
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3">
+ {members.map((member) => (
+ <div key={member.id} className="flex items-center justify-between p-3 border rounded-lg">
+ <div className="flex items-center gap-3">
+ <div className="h-10 w-10 bg-gray-100 rounded-full flex items-center justify-center">
+ {member.user.name?.charAt(0).toUpperCase()}
+ </div>
+ <div>
+ <p className="font-medium">{member.user.name}</p>
+ <p className="text-sm text-muted-foreground">{member.user.email}</p>
+ </div>
+ </div>
+ <Badge variant="secondary">
+ {roleConfig[member.role as keyof typeof roleConfig].icon &&
+ React.createElement(roleConfig[member.role as keyof typeof roleConfig].icon, {
+ className: `h-3 w-3 mr-1 ${roleConfig[member.role as keyof typeof roleConfig].color}`
+ })
+ }
+ {roleConfig[member.role as keyof typeof roleConfig].label}
+ </Badge>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {isOwner && (
+ <TabsContent value="danger" className="mt-6">
+ <Card className="border-red-200">
+ <CardHeader>
+ <CardTitle className="text-red-600">위험 영역</CardTitle>
+ <CardDescription>
+ 이 작업들은 되돌릴 수 없습니다. 신중하게 진행하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-between p-4 border rounded-lg">
+ <div>
+ <h3 className="font-medium">소유권 이전</h3>
+ <p className="text-sm text-muted-foreground">
+ 프로젝트 소유권을 다른 멤버에게 이전합니다
+ </p>
+ </div>
+ <Button
+ variant="outline"
+ onClick={() => setTransferOwnershipOpen(true)}
+ >
+ <UserCog className="h-4 w-4 mr-2" />
+ 소유권 이전
+ </Button>
+ </div>
+
+ <div className="flex items-center justify-between p-4 border rounded-lg border-red-200">
+ <div>
+ <h3 className="font-medium text-red-600">프로젝트 삭제</h3>
+ <p className="text-sm text-muted-foreground">
+ 프로젝트와 모든 파일을 영구적으로 삭제합니다
+ </p>
+ </div>
+ <Button variant="destructive">
+ <Trash2 className="h-4 w-4 mr-2" />
+ 프로젝트 삭제
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ )}
+ </Tabs>
+
+ {/* 멤버 추가 다이얼로그 */}
+ <Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>멤버 추가</DialogTitle>
+ <DialogDescription>
+ 프로젝트에 새 멤버를 추가합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="email">이메일</Label>
+ <Input
+ id="email"
+ type="email"
+ value={newMemberEmail}
+ onChange={(e) => setNewMemberEmail(e.target.value)}
+ placeholder="user@example.com"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="role">역할</Label>
+ <Select value={newMemberRole} onValueChange={setNewMemberRole}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem>
+ <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem>
+ <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setAddMemberOpen(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleAddMember}>추가</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 소유권 이전 다이얼로그 */}
+ <Dialog open={transferOwnershipOpen} onOpenChange={setTransferOwnershipOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>소유권 이전</DialogTitle>
+ <DialogDescription className="text-red-600">
+ 주의: 이 작업은 되돌릴 수 없습니다. 프로젝트의 모든 권한이 새 소유자에게 이전됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div>
+ <Label htmlFor="new-owner">새 소유자 선택</Label>
+ <Select value={newOwnerId} onValueChange={setNewOwnerId}>
+ <SelectTrigger>
+ <SelectValue placeholder="멤버 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {members
+ .filter(m => m.role !== 'owner')
+ .map(member => (
+ <SelectItem key={member.userId} value={member.userId.toString()}>
+ {member.user.name} ({member.user.email})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setTransferOwnershipOpen(false)}>
+ 취소
+ </Button>
+ <Button variant="destructive" onClick={handleTransferOwnership}>
+ 소유권 이전
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/components/project/ProjectHeader.tsx b/components/project/ProjectHeader.tsx
new file mode 100644
index 00000000..34a3f43e
--- /dev/null
+++ b/components/project/ProjectHeader.tsx
@@ -0,0 +1,84 @@
+// components/project/ProjectHeader.tsx
+'use client';
+
+import { useSession } from 'next-auth/react';
+import { Bell, Search, HelpCircle, User } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Badge } from '@/components/ui/badge';
+
+export function ProjectHeader() {
+ const { data: session } = useSession();
+
+ return (
+ <header className="border-b bg-white sticky top-0 z-50">
+ <div className="container mx-auto px-4">
+ <div className="flex items-center justify-between h-16">
+ {/* 로고 */}
+ <div className="flex items-center gap-6">
+ <div className="flex items-center gap-2">
+ <div className="h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center">
+ <span className="text-white font-bold">FM</span>
+ </div>
+ <span className="text-xl font-semibold">File Manager</span>
+ </div>
+ </div>
+
+ {/* 우측 메뉴 */}
+ <div className="flex items-center gap-3">
+ {/* 검색 */}
+ <Button variant="ghost" size="icon">
+ <Search className="h-5 w-5" />
+ </Button>
+
+ {/* 알림 */}
+ <Button variant="ghost" size="icon" className="relative">
+ <Bell className="h-5 w-5" />
+ <span className="absolute top-0 right-0 h-2 w-2 bg-red-500 rounded-full" />
+ </Button>
+
+ {/* 도움말 */}
+ <Button variant="ghost" size="icon">
+ <HelpCircle className="h-5 w-5" />
+ </Button>
+
+ {/* 사용자 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="gap-2">
+ <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center">
+ <User className="h-4 w-4" />
+ </div>
+ <span className="hidden md:inline">{session?.user?.name}</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuLabel>
+ <div>
+ <p className="font-medium">{session?.user?.name}</p>
+ <p className="text-sm text-muted-foreground">{session?.user?.email}</p>
+ </div>
+ </DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem>프로필</DropdownMenuItem>
+ <DropdownMenuItem>설정</DropdownMenuItem>
+ <DropdownMenuItem>팀 관리</DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem className="text-red-600">
+ 로그아웃
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+ </div>
+ </header>
+ );
+} \ No newline at end of file
diff --git a/components/project/ProjectList.tsx b/components/project/ProjectList.tsx
new file mode 100644
index 00000000..4a4f7962
--- /dev/null
+++ b/components/project/ProjectList.tsx
@@ -0,0 +1,463 @@
+// components/project/ProjectList.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { useForm } from 'react-hook-form';
+import {
+ Plus,
+ Folder,
+ Users,
+ Globe,
+ Lock,
+ Crown,
+ Calendar,
+ Search,
+ Filter,
+ Grid3x3,
+ List
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Input } from '@/components/ui/input';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+
+interface Project {
+ id: string;
+ code: string;
+ name: string;
+ description?: string;
+ isPublic: boolean;
+ createdAt: string;
+ updatedAt: string;
+ role?: string;
+ memberCount?: number;
+ fileCount?: number;
+}
+
+interface ProjectFormData {
+ code: string;
+ name: string;
+ description?: string;
+ isPublic: boolean;
+}
+
+export function ProjectList() {
+ const [projects, setProjects] = useState<{
+ owned: Project[];
+ member: Project[];
+ public: Project[];
+ }>({ owned: [], member: [], public: [] });
+ const [searchQuery, setSearchQuery] = useState('');
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const router = useRouter();
+ const { toast } = useToast();
+
+ // React Hook Form 설정
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isValid },
+ watch,
+ setValue,
+ } = useForm<ProjectFormData>({
+ mode: 'onChange',
+ defaultValues: {
+ code: '',
+ name: '',
+ description: '',
+ isPublic: false,
+ },
+ });
+
+ const watchIsPublic = watch('isPublic');
+
+ useEffect(() => {
+ fetchProjects();
+ }, []);
+
+ const fetchProjects = async () => {
+ try {
+ const response = await fetch('/api/projects');
+ const data = await response.json();
+ setProjects(data);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 목록을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const onSubmit = async (data: ProjectFormData) => {
+ setIsSubmitting(true);
+ try {
+ const response = await fetch('/api/projects', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) throw new Error('프로젝트 생성 실패');
+
+ const project = await response.json();
+
+ toast({
+ title: '성공',
+ description: '프로젝트가 생성되었습니다.',
+ });
+
+ setCreateDialogOpen(false);
+ reset();
+ fetchProjects();
+
+ // 생성된 프로젝트로 이동
+ router.push(`/evcp/data-room/${project.id}`);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '프로젝트 생성에 실패했습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleDialogClose = (open: boolean) => {
+ setCreateDialogOpen(open);
+ if (!open) {
+ reset();
+ }
+ };
+
+ const filteredProjects = {
+ owned: projects.owned?.filter(p =>
+ p.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ),
+ member: projects.member?.filter(p =>
+ p.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ),
+ public: projects.public?.filter(p =>
+ p.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ),
+ };
+
+ const ProjectCard = ({ project, role }: { project: Project; role?: string }) => (
+ <Card
+ className="cursor-pointer hover:shadow-lg transition-shadow"
+ onClick={() => router.push(`/evcp/data-room/${project.id}/files`)}
+ >
+ <CardHeader>
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-2">
+ <Folder className="h-5 w-5 text-blue-500" />
+ <CardTitle className="text-base">{project.code} {project.name}</CardTitle>
+ </div>
+ {role === 'owner' && (
+ <Crown className="h-4 w-4 text-yellow-500" />
+ )}
+ {project.isPublic ? (
+ <Globe className="h-4 w-4 text-green-500" />
+ ) : (
+ <Lock className="h-4 w-4 text-gray-500" />
+ )}
+ </div>
+ <CardDescription className="line-clamp-2">
+ {project.description || '설명이 없습니다'}
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
+ <div className="flex items-center gap-3">
+ {project.memberCount && (
+ <span className="flex items-center gap-1">
+ <Users className="h-3 w-3" />
+ {project.memberCount}
+ </span>
+ )}
+ {project.fileCount !== undefined && (
+ <span className="flex items-center gap-1">
+ <Folder className="h-3 w-3" />
+ {project.fileCount}
+ </span>
+ )}
+ </div>
+ <span className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ {new Date(project.updatedAt).toLocaleDateString()}
+ </span>
+ </div>
+ {role && (
+ <Badge variant="secondary" className="mt-2">
+ {role}
+ </Badge>
+ )}
+ </CardContent>
+ </Card>
+ );
+
+ return (
+ <>
+ {/* 헤더 */}
+ <div className="flex items-center justify-between mb-6">
+ <div>
+ <h1 className="text-3xl font-bold">프로젝트</h1>
+ <p className="text-muted-foreground mt-1">
+ 파일을 관리하고 팀과 협업하세요
+ </p>
+ </div>
+ {/* <Button onClick={() => setCreateDialogOpen(true)}>
+ <Plus className="h-4 w-4 mr-2" />
+ 새 프로젝트
+ </Button> */}
+ </div>
+
+ {/* 검색 및 필터 */}
+ <div className="flex items-center gap-3 mb-6">
+ <div className="relative flex-1 max-w-md">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="프로젝트 검색..."
+ className="pl-9"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
+ >
+ {viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid3x3 className="h-4 w-4" />}
+ </Button>
+ </div>
+
+ {/* 프로젝트 목록 */}
+ <Tabs defaultValue="owned" className="space-y-6">
+ <TabsList>
+ <TabsTrigger value="member">
+ 참여 프로젝트 ({filteredProjects.member?.length})
+ </TabsTrigger>
+ <TabsTrigger value="public">
+ 공개 프로젝트 ({filteredProjects.public?.length})
+ </TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="member">
+ {filteredProjects.member?.length === 0 ? (
+ <div className="text-center py-12">
+ <Users className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
+ <p className="text-muted-foreground">참여 중인 프로젝트가 없습니다</p>
+ </div>
+ ) : viewMode === 'grid' ? (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+ {filteredProjects.member?.map(project => (
+ <ProjectCard key={project.id} project={project} role={project.role} />
+ ))}
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredProjects.member?.map(project => (
+ <Card
+ key={project.id}
+ className="cursor-pointer hover:shadow transition-shadow"
+ onClick={() => router.push(`/evcp/data-room/${project.id}/files`)}
+ >
+ <CardContent className="flex items-center justify-between p-4">
+ <div className="flex items-center gap-3">
+ <Folder className="h-5 w-5 text-blue-500" />
+ <div>
+ <p className="font-medium">{project.name}</p>
+ <p className="text-sm text-muted-foreground">
+ {project.description || '설명이 없습니다'}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary">{project.role}</Badge>
+ {project.isPublic ? (
+ <Globe className="h-4 w-4 text-green-500" />
+ ) : (
+ <Lock className="h-4 w-4 text-gray-500" />
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ )}
+ </TabsContent>
+
+ <TabsContent value="public">
+ {filteredProjects.public?.length === 0 ? (
+ <div className="text-center py-12">
+ <Globe className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
+ <p className="text-muted-foreground">공개 프로젝트가 없습니다</p>
+ </div>
+ ) : viewMode === 'grid' ? (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+ {filteredProjects.public?.map(project => (
+ <ProjectCard key={project.id} project={project} />
+ ))}
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredProjects.public?.map(project => (
+ <Card
+ key={project.id}
+ className="cursor-pointer hover:shadow transition-shadow"
+ onClick={() => router.push(`/evcp/data-room/${project.id}/files`)}
+ >
+ <CardContent className="flex items-center justify-between p-4">
+ <div className="flex items-center gap-3">
+ <Globe className="h-5 w-5 text-green-500" />
+ <div>
+ <p className="font-medium">{project.name}</p>
+ <p className="text-sm text-muted-foreground">
+ {project.description || '설명이 없습니다'}
+ </p>
+ </div>
+ </div>
+ <Badge variant="outline">공개</Badge>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ )}
+ </TabsContent>
+ </Tabs>
+
+ {/* 프로젝트 생성 다이얼로그 */}
+ <Dialog open={createDialogOpen} onOpenChange={handleDialogClose}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>새 프로젝트 만들기</DialogTitle>
+ <DialogDescription>
+ 팀과 파일을 공유할 새 프로젝트를 생성합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+ <div>
+ <Label htmlFor="code">
+ 프로젝트 코드 <span className="text-red-500">*</span>
+ </Label>
+ <Input
+ id="code"
+ {...register('code', {
+ required: '프로젝트 코드는 필수입니다',
+ minLength: {
+ value: 2,
+ message: '프로젝트 코드는 최소 2자 이상이어야 합니다',
+ },
+ pattern: {
+ value: /^[A-Z0-9]+$/,
+ message: '프로젝트 코드는 대문자와 숫자만 사용 가능합니다',
+ },
+ })}
+ placeholder="SN1001"
+ className={errors.code ? 'border-red-500' : ''}
+ />
+ {errors.code && (
+ <p className="text-sm text-red-500 mt-1">{errors.code.message}</p>
+ )}
+ </div>
+
+ <div>
+ <Label htmlFor="name">
+ 프로젝트 이름 <span className="text-red-500">*</span>
+ </Label>
+ <Input
+ id="name"
+ {...register('name', {
+ required: '프로젝트 이름은 필수입니다',
+ minLength: {
+ value: 2,
+ message: '프로젝트 이름은 최소 2자 이상이어야 합니다',
+ },
+ maxLength: {
+ value: 50,
+ message: '프로젝트 이름은 50자를 초과할 수 없습니다',
+ },
+ })}
+ placeholder="예: FNLG"
+ className={errors.name ? 'border-red-500' : ''}
+ />
+ {errors.name && (
+ <p className="text-sm text-red-500 mt-1">{errors.name.message}</p>
+ )}
+ </div>
+
+ <div>
+ <Label htmlFor="description">설명 (선택)</Label>
+ <Input
+ id="description"
+ {...register('description', {
+ maxLength: {
+ value: 200,
+ message: '설명은 200자를 초과할 수 없습니다',
+ },
+ })}
+ placeholder="프로젝트에 대한 간단한 설명"
+ className={errors.description ? 'border-red-500' : ''}
+ />
+ {errors.description && (
+ <p className="text-sm text-red-500 mt-1">{errors.description.message}</p>
+ )}
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label htmlFor="public">공개 프로젝트</Label>
+ <p className="text-sm text-muted-foreground">
+ 모든 사용자가 이 프로젝트를 볼 수 있습니다
+ </p>
+ </div>
+ <Switch
+ id="public"
+ checked={watchIsPublic}
+ onCheckedChange={(checked) => setValue('isPublic', checked)}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => handleDialogClose(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={!isValid || isSubmitting}
+ >
+ {isSubmitting ? '생성 중...' : '프로젝트 생성'}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+} \ No newline at end of file
diff --git a/components/project/ProjectNav.tsx b/components/project/ProjectNav.tsx
new file mode 100644
index 00000000..acf9bfd8
--- /dev/null
+++ b/components/project/ProjectNav.tsx
@@ -0,0 +1,149 @@
+// components/project/ProjectNav.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import {
+ Home,
+ FolderOpen,
+ Users,
+ Settings,
+ BarChart3,
+ Share2,
+ ChevronDown,
+ ExternalLink
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbSeparator,BreadcrumbList
+} from '@/components/ui/breadcrumb';
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+
+interface ProjectNavProps {
+ projectId: string;
+}
+
+export function ProjectNav({ projectId }: ProjectNavProps) {
+ const [projectName, setProjectName] = useState('');
+ const [projectRole, setProjectRole] = useState('');
+ const router = useRouter();
+ const pathname = usePathname();
+
+ useEffect(() => {
+ // 프로젝트 정보 가져오기
+ fetchProjectInfo();
+ }, [projectId]);
+
+ const fetchProjectInfo = async () => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}`);
+ const data = await response.json();
+ setProjectName(data.name);
+ setProjectRole(data.role);
+ } catch (error) {
+ console.error('프로젝트 정보 로드 실패:', error);
+ }
+ };
+
+ const navItems = [
+ {
+ label: '대시보드',
+ icon: Home,
+ href: `/evcp/data-room/${projectId}`,
+ active: pathname === `/evcp/data-room/${projectId}`,
+ },
+ {
+ label: '파일',
+ icon: FolderOpen,
+ href: `/evcp/data-room/${projectId}/files`,
+ active: pathname === `/evcp/data-room/${projectId}/files`,
+ },
+ {
+ label: '멤버',
+ icon: Users,
+ href: `/evcp/data-room/${projectId}/members`,
+ active: pathname === `/evcp/data-room/${projectId}/members`,
+ requireRole: ['owner', 'admin'],
+ },
+ {
+ label: '통계',
+ icon: BarChart3,
+ href: `/evcp/data-room/${projectId}/stats`,
+ active: pathname === `/evcp/data-room/${projectId}/stats`,
+ requireRole: ['owner'],
+ },
+ {
+ label: '설정',
+ icon: Settings,
+ href: `/evcp/data-room/${projectId}/settings`,
+ active: pathname === `/evcp/data-room/${projectId}/settings`,
+ requireRole: ['owner', 'admin'],
+ },
+ ];
+
+ const visibleNavItems = navItems.filter(item =>
+ !item.requireRole || item.requireRole.includes(projectRole)
+ );
+
+ return (
+ <div className="border-b bg-white">
+ <div className="px-6 py-3">
+ {/* Breadcrumb */}
+ <div className="flex items-center justify-between mb-3">
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink href="/evcp/data-room">프로젝트</BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ {projectName || '로딩...'}
+ </BreadcrumbItem>
+ </BreadcrumbList>
+ </Breadcrumb>
+
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">
+ {projectRole || 'viewer'}
+ </Badge>
+ <Button variant="outline" size="sm">
+ <Share2 className="h-4 w-4 mr-1" />
+ 공유
+ </Button>
+ </div>
+ </div>
+
+ {/* 네비게이션 탭 */}
+ <div className="flex items-center gap-1">
+ {visibleNavItems.map(item => (
+ <Button
+ key={item.label}
+ variant={item.active ? "secondary" : "ghost"}
+ size="sm"
+ onClick={() => router.push(item.href)}
+ className={cn(
+ "gap-2",
+ item.active && "bg-secondary"
+ )}
+ >
+ <item.icon className="h-4 w-4" />
+ {item.label}
+ </Button>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+}
+
diff --git a/components/project/ProjectSidebar.tsx b/components/project/ProjectSidebar.tsx
new file mode 100644
index 00000000..ce2007b1
--- /dev/null
+++ b/components/project/ProjectSidebar.tsx
@@ -0,0 +1,318 @@
+// components/project/ProjectSidebar.tsx
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import {
+ Home,
+ FolderOpen,
+ Users,
+ Settings,
+ Plus,
+ ChevronLeft,
+ ChevronRight,
+ Search,
+ Crown,
+ Shield,
+ Eye,
+ Clock,
+ Star,
+ LogOut
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Input } from '@/components/ui/input';
+import { Separator } from '@/components/ui/separator';
+import { Badge } from '@/components/ui/badge';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+import { useSession, signOut } from 'next-auth/react';
+
+interface RecentProject {
+ id: string;
+ name: string;
+ role: string;
+ lastAccessed: string;
+}
+
+export function ProjectSidebar() {
+ const [collapsed, setCollapsed] = useState(false);
+ const [recentProjects, setRecentProjects] = useState<RecentProject[]>([]);
+ const [favoriteProjects, setFavoriteProjects] = useState<string[]>([]);
+
+ const router = useRouter();
+ const pathname = usePathname();
+ const { data: session } = useSession();
+
+ const isInternalUser = session?.user?.domain !== 'partners';
+
+ useEffect(() => {
+ // 최근 프로젝트 로드
+ const stored = localStorage.getItem('recentProjects');
+ if (stored) {
+ setRecentProjects(JSON.parse(stored));
+ }
+
+ // 즐겨찾기 프로젝트 로드
+ const favorites = localStorage.getItem('favoriteProjects');
+ if (favorites) {
+ setFavoriteProjects(JSON.parse(favorites));
+ }
+ }, [pathname]);
+
+ const menuItems = [
+ {
+ label: '홈',
+ icon: Home,
+ href: '/projects',
+ active: pathname === '/projects',
+ },
+ {
+ label: '모든 프로젝트',
+ icon: FolderOpen,
+ href: '/projects',
+ active: pathname === '/projects',
+ },
+ ...(isInternalUser ? [{
+ label: '팀 관리',
+ icon: Users,
+ href: '/projects/team',
+ active: pathname === '/projects/team',
+ }] : []),
+ {
+ label: '설정',
+ icon: Settings,
+ href: '/projects/settings',
+ active: pathname === '/projects/settings',
+ },
+ ];
+
+ const roleIcons = {
+ owner: { icon: Crown, color: 'text-yellow-500' },
+ admin: { icon: Shield, color: 'text-blue-500' },
+ viewer: { icon: Eye, color: 'text-gray-500' },
+ };
+
+ return (
+ <TooltipProvider>
+ <div className={cn(
+ "flex flex-col bg-white border-r transition-all duration-300",
+ collapsed ? "w-16" : "w-64"
+ )}>
+ {/* 헤더 */}
+ <div className="flex items-center justify-between p-4 border-b">
+ {!collapsed && (
+ <div>
+ <h2 className="text-lg font-semibold">파일 매니저</h2>
+ <p className="text-xs text-muted-foreground">
+ {session?.user?.name}
+ </p>
+ </div>
+ )}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setCollapsed(!collapsed)}
+ className={cn(collapsed && "mx-auto")}
+ >
+ {collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
+ </Button>
+ </div>
+
+ {/* 검색 */}
+ {!collapsed && (
+ <div className="p-3 border-b">
+ <div className="relative">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="프로젝트 검색..."
+ className="pl-8 h-8"
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 메인 메뉴 */}
+ <ScrollArea className="flex-1">
+ <div className="p-2">
+ <div className={cn(!collapsed && "mb-3")}>
+ {!collapsed && (
+ <p className="text-xs text-muted-foreground px-2 mb-2">메뉴</p>
+ )}
+ {menuItems.map((item) => (
+ <Tooltip key={item.label} delayDuration={0}>
+ <TooltipTrigger asChild>
+ <Button
+ variant={item.active ? "secondary" : "ghost"}
+ className={cn(
+ "w-full justify-start mb-1",
+ collapsed && "justify-center"
+ )}
+ onClick={() => router.push(item.href)}
+ >
+ <item.icon className={cn("h-4 w-4", !collapsed && "mr-2")} />
+ {!collapsed && item.label}
+ </Button>
+ </TooltipTrigger>
+ {collapsed && (
+ <TooltipContent side="right">
+ {item.label}
+ </TooltipContent>
+ )}
+ </Tooltip>
+ ))}
+ </div>
+
+ <Separator className="my-3" />
+
+ {/* 빠른 액세스 */}
+ {!collapsed && (
+ <div className="mb-3">
+ <div className="flex items-center justify-between px-2 mb-2">
+ <p className="text-xs text-muted-foreground">빠른 액세스</p>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => router.push('/projects/new')}
+ >
+ <Plus className="h-3 w-3" />
+ </Button>
+ </div>
+
+ {/* 즐겨찾기 프로젝트 */}
+ {favoriteProjects.length > 0 && (
+ <div className="space-y-1 mb-3">
+ {favoriteProjects.slice(0, 3).map((projectId) => (
+ <Button
+ key={projectId}
+ variant="ghost"
+ className="w-full justify-start h-8 px-2"
+ onClick={() => router.push(`/projects/${projectId}/files`)}
+ >
+ <Star className="h-3 w-3 mr-2 text-yellow-500" />
+ <span className="text-sm truncate">프로젝트 이름</span>
+ </Button>
+ ))}
+ </div>
+ )}
+
+ {/* 최근 프로젝트 */}
+ <div className="space-y-1">
+ <p className="text-xs text-muted-foreground px-2 mb-1">최근 프로젝트</p>
+ {recentProjects.slice(0, 5).map((project) => {
+ const RoleIcon = roleIcons[project.role as keyof typeof roleIcons];
+ return (
+ <Button
+ key={project.id}
+ variant="ghost"
+ className="w-full justify-start h-8 px-2 group"
+ onClick={() => router.push(`/projects/${project.id}/files`)}
+ >
+ {RoleIcon && (
+ <RoleIcon.icon className={cn("h-3 w-3 mr-2", RoleIcon.color)} />
+ )}
+ <span className="text-sm truncate flex-1 text-left">
+ {project.name}
+ </span>
+ <Clock className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
+ </Button>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {collapsed && (
+ <div className="space-y-1">
+ <Tooltip delayDuration={0}>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ className="w-full justify-center"
+ onClick={() => router.push('/projects/new')}
+ >
+ <Plus className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right">
+ 새 프로젝트
+ </TooltipContent>
+ </Tooltip>
+
+ {recentProjects.slice(0, 3).map((project) => {
+ const RoleIcon = roleIcons[project.role as keyof typeof roleIcons];
+ return (
+ <Tooltip key={project.id} delayDuration={0}>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ className="w-full justify-center"
+ onClick={() => router.push(`/projects/${project.id}/files`)}
+ >
+ {RoleIcon && (
+ <RoleIcon.icon className={cn("h-4 w-4", RoleIcon.color)} />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right">
+ {project.name}
+ </TooltipContent>
+ </Tooltip>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+
+ {/* 하단 사용자 정보 */}
+ <div className="border-t p-3">
+ {!collapsed ? (
+ <div className="flex items-center gap-2">
+ <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center">
+ <span className="text-xs font-medium">
+ {session?.user?.name?.charAt(0).toUpperCase()}
+ </span>
+ </div>
+ <div className="flex-1">
+ <p className="text-sm font-medium truncate">{session?.user?.name}</p>
+ <Badge variant="outline" className="text-xs">
+ {isInternalUser ? '내부' : '외부'}
+ </Badge>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => signOut()}
+ >
+ <LogOut className="h-4 w-4" />
+ </Button>
+ </div>
+ ) : (
+ <Tooltip delayDuration={0}>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ className="w-full justify-center"
+ onClick={() => signOut()}
+ >
+ <LogOut className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right">
+ 로그아웃
+ </TooltipContent>
+ </Tooltip>
+ )}
+ </div>
+ </div>
+ </TooltipProvider>
+ );
+}
+
diff --git a/db/schema/fileSystem.ts b/db/schema/fileSystem.ts
new file mode 100644
index 00000000..a66e3180
--- /dev/null
+++ b/db/schema/fileSystem.ts
@@ -0,0 +1,377 @@
+// db/schema/fileSystem.ts
+import {
+ pgTable,
+ varchar,
+ integer,
+ timestamp,
+ boolean,
+ text,
+ jsonb,
+ uuid,
+ bigint,
+ uniqueIndex,
+ index,
+ pgEnum,
+ primaryKey,
+} from "drizzle-orm/pg-core";
+import { relations } from "drizzle-orm";
+import { users } from "./users"; // 기존 users 테이블
+
+// 파일 접근 레벨 Enum
+export const fileAccessLevelEnum = pgEnum("file_access_level", [
+ "view_only", // 열람만 가능
+ "view_download", // 열람 + 다운로드
+ "full_access", // 모든 권한 (내부 사용자 기본)
+]);
+
+// 파일 타입 Enum
+export const fileTypeEnum = pgEnum("file_type", ["file", "folder"]);
+
+// 파일 카테고리 Enum (외부 사용자 접근 권한 분류)
+export const fileCategoryEnum = pgEnum("file_category", [
+ "public", // 외부 사용자 열람 + 다운로드 가능
+ "restricted", // 외부 사용자 열람만 가능
+ "confidential", // 외부 사용자 접근 불가
+ "internal", // 내부 전용
+]);
+
+// 프로젝트 테이블
+export const fileSystemProjects = pgTable("file_system_projects", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ code: varchar("code", { length: 50 }).notNull(),
+
+ name: varchar("name", { length: 255 }).notNull(),
+ description: text("description"),
+ ownerId: integer("owner_id")
+ .references(() => users.id, { onDelete: "set null" }),
+ isPublic: boolean("is_public").default(false).notNull(), // 외부 공개 여부
+ externalAccessEnabled: boolean("external_access_enabled").default(false).notNull(),
+ metadata: jsonb("metadata").default({}).notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+}, (table) => ({
+ ownerIdx: index("projects_owner_idx").on(table.ownerId),
+}));
+
+// 파일/폴더 테이블
+export const fileItems = pgTable("file_items", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ projectId: uuid("project_id")
+ .references(() => fileSystemProjects.id, { onDelete: "cascade" })
+ .notNull(),
+ parentId: uuid("parent_id")
+ .references(() => fileItems.id, { onDelete: "cascade" }),
+ name: varchar("name", { length: 255 }).notNull(),
+ type: fileTypeEnum("type").notNull(),
+
+ // 파일 정보
+ mimeType: varchar("mime_type", { length: 255 }),
+ size: bigint("size", { mode: "number" }).default(0).notNull(),
+ filePath: text("file_path"), // S3 키 또는 로컬 경로
+ fileUrl: text("file_url"), // 직접 접근 URL (CDN 등)
+
+ // 권한 카테고리 (외부 사용자용)
+ category: fileCategoryEnum("category").default("confidential").notNull(),
+
+ // 외부 접근 설정
+ externalAccessLevel: fileAccessLevelEnum("external_access_level").default("view_only"),
+ externalAccessExpiry: timestamp("external_access_expiry", { withTimezone: true }),
+ downloadCount: integer("download_count").default(0).notNull(),
+ viewCount: integer("view_count").default(0).notNull(),
+
+ // 메타데이터
+ metadata: jsonb("metadata").default({}).notNull(),
+ tags: text("tags").array(), // 태그 배열
+
+ // 버전 관리
+ version: integer("version").default(1).notNull(),
+ previousVersionId: uuid("previous_version_id"),
+
+ // 감사 로그
+ createdBy: integer("created_by")
+ .references(() => users.id, { onDelete: "set null" }),
+ updatedBy: integer("updated_by")
+ .references(() => users.id, { onDelete: "set null" }),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+
+ // 경로 최적화
+ path: text("path").notNull().default("/"),
+ depth: integer("depth").notNull().default(0),
+}, (table) => ({
+ projectPathIdx: uniqueIndex("file_items_project_path_idx").on(
+ table.projectId,
+ table.path,
+ table.name
+ ),
+ parentIdx: index("file_items_parent_idx").on(table.parentId),
+ categoryIdx: index("file_items_category_idx").on(table.category),
+ createdByIdx: index("file_items_created_by_idx").on(table.createdBy),
+ tagsIdx: index("file_items_tags_idx").on(table.tags),
+}));
+
+// 파일 공유 링크 테이블
+export const fileShares = pgTable("file_shares", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ fileItemId: uuid("file_item_id")
+ .references(() => fileItems.id, { onDelete: "cascade" })
+ .notNull(),
+ shareToken: varchar("share_token", { length: 64 }).notNull().unique(),
+
+ // 공유 설정
+ accessLevel: fileAccessLevelEnum("access_level").default("view_only").notNull(),
+ password: varchar("password", { length: 255 }), // 선택적 비밀번호
+ maxDownloads: integer("max_downloads"), // 최대 다운로드 횟수
+ currentDownloads: integer("current_downloads").default(0).notNull(),
+
+ // 유효기간
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
+
+ // 공유 대상 (선택적)
+ sharedWithEmail: varchar("shared_with_email", { length: 255 }),
+ sharedWithUserId: integer("shared_with_user_id")
+ .references(() => users.id, { onDelete: "set null" }),
+
+ // 감사 로그
+ createdBy: integer("created_by")
+ .references(() => users.id, { onDelete: "set null" }),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ lastAccessedAt: timestamp("last_accessed_at", { withTimezone: true }),
+}, (table) => ({
+ tokenIdx: uniqueIndex("file_shares_token_idx").on(table.shareToken),
+ fileIdx: index("file_shares_file_idx").on(table.fileItemId),
+ expiryIdx: index("file_shares_expiry_idx").on(table.expiresAt),
+}));
+
+// 세밀한 파일 권한 테이블 (특정 사용자/그룹에 대한 예외 권한)
+export const filePermissions = pgTable("file_permissions", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ fileItemId: uuid("file_item_id")
+ .references(() => fileItems.id, { onDelete: "cascade" })
+ .notNull(),
+
+ // 대상 (사용자 또는 도메인)
+ userId: integer("user_id")
+ .references(() => users.id, { onDelete: "cascade" }),
+ userDomain: varchar("user_domain", { length: 50 }), // 'partners', 'internal' 등
+
+ // 권한
+ canView: boolean("can_view").default(true).notNull(),
+ canDownload: boolean("can_download").default(false).notNull(),
+ canEdit: boolean("can_edit").default(false).notNull(),
+ canDelete: boolean("can_delete").default(false).notNull(),
+ canShare: boolean("can_share").default(false).notNull(),
+
+ // 유효기간
+ validFrom: timestamp("valid_from", { withTimezone: true }),
+ validUntil: timestamp("valid_until", { withTimezone: true }),
+
+ // 감사 로그
+ grantedBy: integer("granted_by")
+ .references(() => users.id, { onDelete: "set null" }),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+}, (table) => ({
+ fileUserIdx: uniqueIndex("file_permissions_file_user_idx").on(
+ table.fileItemId,
+ table.userId
+ ),
+ fileIdx: index("file_permissions_file_idx").on(table.fileItemId),
+ userIdx: index("file_permissions_user_idx").on(table.userId),
+ domainIdx: index("file_permissions_domain_idx").on(table.userDomain),
+}));
+
+// 파일 활동 로그 테이블
+export const fileActivityLogs = pgTable("file_activity_logs", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ fileItemId: uuid("file_item_id")
+ .references(() => fileItems.id, { onDelete: "cascade" })
+ .notNull(),
+ projectId: uuid("project_id")
+ .references(() => fileSystemProjects.id, { onDelete: "cascade" })
+ .notNull(),
+
+ // 활동 정보
+ action: varchar("action", { length: 50 }).notNull(), // 'view', 'download', 'upload', 'edit', 'delete', 'share'
+ actionDetails: jsonb("action_details").default({}).notNull(),
+
+ // 사용자 정보
+ userId: integer("user_id")
+ .references(() => users.id, { onDelete: "set null" }),
+ userEmail: varchar("user_email", { length: 255 }),
+ userDomain: varchar("user_domain", { length: 50 }),
+ ipAddress: varchar("ip_address", { length: 45 }),
+ userAgent: text("user_agent"),
+
+ // 공유 링크를 통한 접근인 경우
+ shareId: uuid("share_id")
+ .references(() => fileShares.id, { onDelete: "set null" }),
+
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+}, (table) => ({
+ fileIdx: index("file_activity_logs_file_idx").on(table.fileItemId),
+ projectIdx: index("file_activity_logs_project_idx").on(table.projectId),
+ userIdx: index("file_activity_logs_user_idx").on(table.userId),
+ actionIdx: index("file_activity_logs_action_idx").on(table.action),
+ createdAtIdx: index("file_activity_logs_created_at_idx").on(table.createdAt),
+}));
+
+// Relations
+export const projectsFilesRelations = relations(fileSystemProjects, ({ one, many }) => ({
+ owner: one(users, {
+ fields: [fileSystemProjects.ownerId],
+ references: [users.id],
+ }),
+ fileItems: many(fileItems),
+}));
+
+
+export const fileItemsRelations = relations(fileItems, ({ one, many }) => ({
+ project: one(fileSystemProjects, {
+ fields: [fileItems.projectId],
+ references: [fileSystemProjects.id],
+ }),
+ parent: one(fileItems, {
+ fields: [fileItems.parentId],
+ references: [fileItems.id],
+ relationName: "parentChild",
+ }),
+ children: many(fileItems, {
+ relationName: "parentChild",
+ }),
+ createdByUser: one(users, {
+ fields: [fileItems.createdBy],
+ references: [users.id],
+ relationName: "createdFiles",
+ }),
+ updatedByUser: one(users, {
+ fields: [fileItems.updatedBy],
+ references: [users.id],
+ relationName: "updatedFiles",
+ }),
+ permissions: many(filePermissions),
+ shares: many(fileShares),
+ activityLogs: many(fileActivityLogs),
+}));
+
+export const filePermissionsRelations = relations(filePermissions, ({ one }) => ({
+ fileItem: one(fileItems, {
+ fields: [filePermissions.fileItemId],
+ references: [fileItems.id],
+ }),
+ user: one(users, {
+ fields: [filePermissions.userId],
+ references: [users.id],
+ }),
+ grantedByUser: one(users, {
+ fields: [filePermissions.grantedBy],
+ references: [users.id],
+ relationName: "grantedPermissions",
+ }),
+}));
+
+export const fileSharesRelations = relations(fileShares, ({ one }) => ({
+ fileItem: one(fileItems, {
+ fields: [fileShares.fileItemId],
+ references: [fileItems.id],
+ }),
+ createdByUser: one(users, {
+ fields: [fileShares.createdBy],
+ references: [users.id],
+ }),
+ sharedWithUser: one(users, {
+ fields: [fileShares.sharedWithUserId],
+ references: [users.id],
+ relationName: "receivedShares",
+ }),
+}));
+
+export const fileActivityLogsRelations = relations(fileActivityLogs, ({ one }) => ({
+ fileItem: one(fileItems, {
+ fields: [fileActivityLogs.fileItemId],
+ references: [fileItems.id],
+ }),
+ project: one(fileSystemProjects, {
+ fields: [fileActivityLogs.projectId],
+ references: [fileSystemProjects.id],
+ }),
+ user: one(users, {
+ fields: [fileActivityLogs.userId],
+ references: [users.id],
+ }),
+ share: one(fileShares, {
+ fields: [fileActivityLogs.shareId],
+ references: [fileShares.id],
+ }),
+}));
+
+// Type exports
+export type FileItem = typeof fileItems.$inferSelect;
+export type NewFileItem = typeof fileItems.$inferInsert;
+export type FilePermission = typeof filePermissions.$inferSelect;
+export type NewFilePermission = typeof filePermissions.$inferInsert;
+export type FileShare = typeof fileShares.$inferSelect;
+export type NewFileShare = typeof fileShares.$inferInsert;
+export type FileActivityLog = typeof fileActivityLogs.$inferSelect;
+export type NewFileActivityLog = typeof fileActivityLogs.$inferInsert;
+
+
+
+
+export type FileSystemProject = typeof fileSystemProjects.$inferSelect;
+export type NewFileSystemProject = typeof fileSystemProjects.$inferInsert;
+
+// db/schema/fileSystem.ts에 추가할 테이블
+export const projectMemberRoleEnum = pgEnum("project_member_role", [
+ "owner",
+ "admin",
+ "editor",
+ "viewer",
+]);
+
+export const projectMembers = pgTable("project_members", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ projectId: uuid("project_id")
+ .references(() => fileSystemProjects.id, { onDelete: "cascade" })
+ .notNull(),
+ userId: integer("user_id")
+ .references(() => users.id, { onDelete: "cascade" })
+ .notNull(),
+ role: projectMemberRoleEnum("role").notNull().default("viewer"),
+
+ // 권한 세부 설정 (역할 외 추가 권한)
+ canInvite: boolean("can_invite").default(false).notNull(),
+ canManageFiles: boolean("can_manage_files").default(false).notNull(),
+ canManageMembers: boolean("can_manage_members").default(false).notNull(),
+
+ // 감사 로그
+ addedBy: integer("added_by")
+ .references(() => users.id, { onDelete: "set null" }),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
+}, (table) => ({
+ // 한 프로젝트에 한 사용자는 하나의 역할만
+ projectUserUnique: uniqueIndex("project_members_project_user_idx").on(
+ table.projectId,
+ table.userId
+ ),
+ userIdx: index("project_members_user_idx").on(table.userId),
+ roleIdx: index("project_members_role_idx").on(table.role),
+}));
+
+// Relations 추가
+export const projectMembersRelations = relations(projectMembers, ({ one }) => ({
+ project: one(fileSystemProjects, {
+ fields: [projectMembers.projectId],
+ references: [fileSystemProjects.id],
+ }),
+ user: one(users, {
+ fields: [projectMembers.userId],
+ references: [users.id],
+ }),
+ addedByUser: one(users, {
+ fields: [projectMembers.addedBy],
+ references: [users.id],
+ relationName: "addedMembers",
+ }),
+})); \ No newline at end of file
diff --git a/db/schema/index.ts b/db/schema/index.ts
index 2bbdb267..a223f0de 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -44,6 +44,8 @@ export * from './generalContract';
export * from './rfqLastTBE';
export * from './pcr';
+export * from './fileSystem';
+
// 부서별 도메인 할당 관리
export * from './departmentDomainAssignments';
diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts
index c8417901..61545d95 100644
--- a/lib/gtc-contract/service.ts
+++ b/lib/gtc-contract/service.ts
@@ -319,6 +319,7 @@ export async function getUsersForFilter(): Promise<UserForFilter[]> {
id: users.id,
name: users.name,
email: users.email,
+ domain: users.domain,
})
.from(users)
.where(eq(users.isActive, true)) // 활성 사용자만
diff --git a/lib/itb/service.ts b/lib/itb/service.ts
index f649bdf5..181285cc 100644
--- a/lib/itb/service.ts
+++ b/lib/itb/service.ts
@@ -3,7 +3,7 @@
import db from "@/db/db";
import { purchaseRequestsView, purchaseRequests, purchaseRequestAttachments, rfqsLast, rfqLastAttachments, rfqLastAttachmentRevisions, rfqPrItems, users } from "@/db/schema";
-import { eq, and, desc, ilike, or, sql, asc, inArray ,like} from "drizzle-orm";
+import { eq, and, desc, ilike, or, sql, asc, inArray, like } from "drizzle-orm";
import { revalidatePath, revalidateTag } from "next/cache";
import { getServerSession } from 'next-auth/next'
import { authOptions } from '@/app/api/auth/[...nextauth]/route'
@@ -293,7 +293,7 @@ export async function approvePurchaseRequestAndCreateRfq(
.where(eq(purchaseRequestAttachments.requestId, requestId));
- const rfqCode = await generateItbRfqCode(purchasePicId);
+ const rfqCode = await generateItbRfqCode(purchasePicId);
const [rfq] = await tx.insert(rfqsLast).values({
rfqCode,
@@ -547,10 +547,10 @@ export async function getPurchaseRequestAttachments(requestId: number) {
}
} catch (error) {
console.error("Get attachments error:", error)
- return {
+ return {
success: false,
error: "첨부파일 조회 중 오류가 발생했습니다.",
- data: []
+ data: []
}
}
}
@@ -558,223 +558,226 @@ export async function getPurchaseRequestAttachments(requestId: number) {
export async function generateItbRfqCode(purchasePicId?: number): Promise<string> {
try {
- let userCode = "???";
-
- // purchasePicId가 있으면 users 테이블에서 userCode 조회
- if (purchasePicId) {
- const [user] = await db
- .select({ userCode: users.userCode })
- .from(users)
- .where(eq(users.id, purchasePicId))
- .limit(1);
-
- if (user?.userCode) {
- userCode = user.userCode;
+ let userCode = "???";
+
+ // purchasePicId가 있으면 users 테이블에서 userCode 조회
+ if (purchasePicId) {
+ const [user] = await db
+ .select({ userCode: users.userCode })
+ .from(users)
+ .where(eq(users.id, purchasePicId))
+ .limit(1);
+
+ if (user?.userCode) {
+ userCode = user.userCode;
+ }
}
- }
-
- // 동일한 userCode로 시작하는 마지막 RFQ 조회
- const lastRfq = await db
- .select({ rfqCode: rfqsLast.rfqCode })
- .from(rfqsLast)
- .where(like(rfqsLast.rfqCode, `I${userCode}%`))
- .orderBy(desc(rfqsLast.createdAt))
- .limit(1);
-
- let nextNumber = 1;
-
- if (lastRfq.length > 0 && lastRfq[0].rfqCode) {
- const rfqCode = lastRfq[0].rfqCode;
- const serialNumber = rfqCode.slice(-5); // 마지막 5자리
-
- if (/^\d{5}$/.test(serialNumber)) {
- nextNumber = parseInt(serialNumber) + 1;
+
+ // 동일한 userCode로 시작하는 마지막 RFQ 조회
+ const lastRfq = await db
+ .select({ rfqCode: rfqsLast.rfqCode })
+ .from(rfqsLast)
+ .where(like(rfqsLast.rfqCode, `I${userCode}%`))
+ .orderBy(desc(rfqsLast.createdAt))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (lastRfq.length > 0 && lastRfq[0].rfqCode) {
+ const rfqCode = lastRfq[0].rfqCode;
+ const serialNumber = rfqCode.slice(-5); // 마지막 5자리
+
+ if (/^\d{5}$/.test(serialNumber)) {
+ nextNumber = parseInt(serialNumber) + 1;
+ }
}
- }
-
- const paddedNumber = String(nextNumber).padStart(5, "0");
-
- return `I${userCode}${paddedNumber}`;
+
+ const paddedNumber = String(nextNumber).padStart(5, "0");
+
+ return `I${userCode}${paddedNumber}`;
} catch (error) {
- console.error("Error generating ITB RFQ code:", error);
- const fallback = Date.now().toString().slice(-5);
- return `I???${fallback}`;
+ console.error("Error generating ITB RFQ code:", error);
+ const fallback = Date.now().toString().slice(-5);
+ return `I???${fallback}`;
}
- }
-
+}
+
- // lib/purchase-requests/service.ts에 추가
+// lib/purchase-requests/service.ts에 추가
// 여러 구매 요청 승인 및 RFQ 생성
export async function approvePurchaseRequestsAndCreateRfqs(
requestIds: number[],
purchasePicId?: number
- ) {
+) {
try {
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) throw new Error("Unauthorized");
- const userId = Number(session.user.id)
-
- const results = []
-
- for (const requestId of requestIds) {
- try {
- const result = await db.transaction(async (tx) => {
- // 구매 요청 조회
- const [request] = await tx
- .select()
- .from(purchaseRequests)
- .where(eq(purchaseRequests.id, requestId))
-
- if (!request) {
- throw new Error(`구매 요청 ${requestId}를 찾을 수 없습니다.`)
- }
-
- if (request.status === "RFQ생성완료") {
- return { skipped: true, requestId, message: "이미 RFQ가 생성되었습니다." }
- }
-
- const attachments = await tx
- .select()
- .from(purchaseRequestAttachments)
- .where(eq(purchaseRequestAttachments.requestId, requestId))
-
- const rfqCode = await generateItbRfqCode(purchasePicId)
-
- // 마감일 기본값 설정 (입력값 없으면 생성일 + 7일)
- const defaultDueDate = getDefaultDueDate();
-
- const [rfq] = await tx
- .insert(rfqsLast)
- .values({
- rfqCode,
- projectId: request.projectId,
- itemCode: request.items?.[0]?.itemCode,
- itemName: request.items?.[0]?.itemName,
- packageNo: request.packageNo,
- packageName: request.packageName,
- EngPicName: request.engPicName,
- pic: purchasePicId || null,
- status: "RFQ 생성",
- dueDate: defaultDueDate, // 마감일 기본값 설정
- projectCompany: request.projectCompany,
- projectSite: request.projectSite,
- smCode: request.smCode,
- createdBy: userId,
- updatedBy: userId,
- })
- .returning()
-
- // 첨부파일 이관
- for (const [index, attachment] of attachments.entries()) {
- const [rfqAttachment] = await tx
- .insert(rfqLastAttachments)
- .values({
- attachmentType: "설계",
- serialNo: `ENG-${String(index + 1).padStart(3, "0")}`,
- rfqId: rfq.id,
- description:
- attachment.description ||
- `설계문서 - ${attachment.originalFileName}`,
- currentRevision: "Rev.0",
- createdBy: userId,
- })
- .returning()
-
- const [revision] = await tx
- .insert(rfqLastAttachmentRevisions)
- .values({
- attachmentId: rfqAttachment.id,
- revisionNo: "Rev.0",
- revisionComment: "구매 요청에서 이관된 설계 문서",
- isLatest: true,
- fileName: attachment.fileName,
- originalFileName: attachment.originalFileName,
- filePath: attachment.filePath,
- fileSize: attachment.fileSize,
- fileType: attachment.fileType,
- createdBy: userId,
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) throw new Error("Unauthorized");
+ const userId = Number(session.user.id)
+
+ const results = []
+
+ for (const requestId of requestIds) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 구매 요청 조회
+ const [request] = await tx
+ .select()
+ .from(purchaseRequests)
+ .where(eq(purchaseRequests.id, requestId))
+
+ if (!request) {
+ throw new Error(`구매 요청 ${requestId}를 찾을 수 없습니다.`)
+ }
+
+ if (request.status === "RFQ생성완료") {
+ return { skipped: true, requestId, message: "이미 RFQ가 생성되었습니다." }
+ }
+
+ const attachments = await tx
+ .select()
+ .from(purchaseRequestAttachments)
+ .where(eq(purchaseRequestAttachments.requestId, requestId))
+
+ const rfqCode = await generateItbRfqCode(purchasePicId)
+
+ const defaultDueDate = (() => {
+ const d = new Date();
+ d.setDate(d.getDate() + 15);
+ return d;
+ })();
+
+
+ const [rfq] = await tx
+ .insert(rfqsLast)
+ .values({
+ rfqCode,
+ projectId: request.projectId,
+ itemCode: request.items?.[0]?.itemCode,
+ itemName: request.items?.[0]?.itemName,
+ packageNo: request.packageNo,
+ packageName: request.packageName,
+ EngPicName: request.engPicName,
+ pic: purchasePicId || null,
+ status: "RFQ 생성",
+ dueDate: defaultDueDate, // 마감일 기본값 설정
+ projectCompany: request.projectCompany,
+ projectSite: request.projectSite,
+ smCode: request.smCode,
+ createdBy: userId,
+ updatedBy: userId,
+ })
+ .returning()
+
+ // 첨부파일 이관
+ for (const [index, attachment] of attachments.entries()) {
+ const [rfqAttachment] = await tx
+ .insert(rfqLastAttachments)
+ .values({
+ attachmentType: "설계",
+ serialNo: `ENG-${String(index + 1).padStart(3, "0")}`,
+ rfqId: rfq.id,
+ description:
+ attachment.description ||
+ `설계문서 - ${attachment.originalFileName}`,
+ currentRevision: "Rev.0",
+ createdBy: userId,
+ })
+ .returning()
+
+ const [revision] = await tx
+ .insert(rfqLastAttachmentRevisions)
+ .values({
+ attachmentId: rfqAttachment.id,
+ revisionNo: "Rev.0",
+ revisionComment: "구매 요청에서 이관된 설계 문서",
+ isLatest: true,
+ fileName: attachment.fileName,
+ originalFileName: attachment.originalFileName,
+ filePath: attachment.filePath,
+ fileSize: attachment.fileSize,
+ fileType: attachment.fileType,
+ createdBy: userId,
+ })
+ .returning()
+
+ await tx
+ .update(rfqLastAttachments)
+ .set({ latestRevisionId: revision.id })
+ .where(eq(rfqLastAttachments.id, rfqAttachment.id))
+ }
+
+ // 품목 이관
+ if (request.items && request.items.length > 0) {
+ console.log("🚀 품목 이관 시작:", {
+ requestId,
+ itemsCount: request.items.length,
+ items: request.items
+ });
+
+ const prItemsData = request.items.map((item, index) => ({
+ rfqsLastId: rfq.id,
+ rfqItem: `${index + 1}`.padStart(3, '0'),
+ prItem: `${index + 1}`.padStart(3, '0'),
+ prNo: rfqCode,
+ materialCategory: request.majorItemMaterialCategory,
+ materialCode: item.itemCode,
+ materialDescription: item.itemName,
+ quantity: item.quantity,
+ uom: item.unit,
+ majorYn: index === 0,
+ remark: item.remarks || null,
+ }));
+
+ console.log("🔍 삽입할 데이터:", prItemsData);
+
+ const insertedItems = await tx.insert(rfqPrItems).values(prItemsData).returning();
+ console.log("✅ 품목 이관 완료:", insertedItems);
+ } else {
+ console.log("❌ 품목이 없음:", {
+ requestId,
+ hasItems: !!request.items,
+ itemsLength: request.items?.length || 0
+ });
+ }
+
+ // 구매 요청 상태 업데이트
+ await tx
+ .update(purchaseRequests)
+ .set({
+ status: "RFQ생성완료",
+ rfqId: rfq.id,
+ rfqCode: rfq.rfqCode,
+ rfqCreatedAt: new Date(),
+ purchasePicId,
+ updatedBy: userId,
+ updatedAt: new Date(),
+ })
+ .where(eq(purchaseRequests.id, requestId))
+
+ return { success: true, rfq, requestId }
})
- .returning()
-
- await tx
- .update(rfqLastAttachments)
- .set({ latestRevisionId: revision.id })
- .where(eq(rfqLastAttachments.id, rfqAttachment.id))
- }
-
- // 품목 이관
- if (request.items && request.items.length > 0) {
- console.log("🚀 품목 이관 시작:", {
- requestId,
- itemsCount: request.items.length,
- items: request.items
- });
-
- const prItemsData = request.items.map((item, index) => ({
- rfqsLastId: rfq.id,
- rfqItem: `${index + 1}`.padStart(3, '0'),
- prItem: `${index + 1}`.padStart(3, '0'),
- prNo: rfqCode,
- materialCategory:request.majorItemMaterialCategory,
- materialCode: item.itemCode,
- materialDescription: item.itemName,
- quantity: item.quantity,
- uom: item.unit,
- majorYn: index === 0,
- remark: item.remarks || null,
- }));
-
- console.log("🔍 삽입할 데이터:", prItemsData);
-
- const insertedItems = await tx.insert(rfqPrItems).values(prItemsData).returning();
- console.log("✅ 품목 이관 완료:", insertedItems);
- } else {
- console.log("❌ 품목이 없음:", {
+
+ results.push(result)
+ } catch (err: any) {
+ console.error(`구매 요청 ${requestId} 처리 중 오류:`, err)
+ results.push({
+ success: false,
requestId,
- hasItems: !!request.items,
- itemsLength: request.items?.length || 0
- });
+ error: err.message || "알 수 없는 오류 발생",
+ })
}
-
- // 구매 요청 상태 업데이트
- await tx
- .update(purchaseRequests)
- .set({
- status: "RFQ생성완료",
- rfqId: rfq.id,
- rfqCode: rfq.rfqCode,
- rfqCreatedAt: new Date(),
- purchasePicId,
- updatedBy: userId,
- updatedAt: new Date(),
- })
- .where(eq(purchaseRequests.id, requestId))
-
- return { success: true, rfq, requestId }
- })
-
- results.push(result)
- } catch (err: any) {
- console.error(`구매 요청 ${requestId} 처리 중 오류:`, err)
- results.push({
- success: false,
- requestId,
- error: err.message || "알 수 없는 오류 발생",
- })
}
- }
-
- // 캐시 무효화
- revalidateTag("purchase-requests")
- revalidateTag( "purchase-request-stats")
-
- revalidateTag("rfqs")
-
- return results
+
+ // 캐시 무효화
+ revalidateTag("purchase-requests")
+ revalidateTag("purchase-request-stats")
+
+ revalidateTag("rfqs")
+
+ return results
} catch (err: any) {
- console.error("approvePurchaseRequestsAndCreateRfqs 실행 오류:", err)
- throw new Error(err.message || "구매 요청 처리 중 오류가 발생했습니다.")
+ console.error("approvePurchaseRequestsAndCreateRfqs 실행 오류:", err)
+ throw new Error(err.message || "구매 요청 처리 중 오류가 발생했습니다.")
}
- }
- \ No newline at end of file
+}
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 82f8837a..be8e13e6 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -357,6 +357,8 @@ export async function createGeneralRfqAction(input: CreateGeneralRfqInput) {
// 5. 마감일 기본값 설정 (입력값 없으면 생성일 + 7일)
const dueDate = input.dueDate || getDefaultDueDate();
+ console.log(dueDate,"dueDate")
+
// 6. rfqsLast 테이블에 기본 정보 삽입
const [newRfq] = await tx
.insert(rfqsLast)
@@ -3798,8 +3800,8 @@ export async function updateRfqDueDate(
}
// 6. 각 vendor별로 이메일 발송
- const emailPromises = []
-
+ const emailPromises: Promise<any>[] = []
+
for (const detail of rfqDetailsData) {
if (!detail.emailSentTo) continue
diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
index c9790880..4a8960ff 100644
--- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
+++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
@@ -53,6 +53,8 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
const currency = watch("vendorCurrency") || "USD"
const quotationItems = watch("quotationItems")
+
+ console.log(prItems,"prItems")
// PR 아이템 정보를 quotationItems에 초기화
useEffect(() => {
diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
index 6da704cd..569546dd 100644
--- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
+++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
@@ -19,6 +19,22 @@ import { Shield, FileText, CheckCircle, XCircle, Clock, Download, Eye, Save, Sen
import { Progress } from "@/components/ui/progress"
import { Alert, AlertDescription } from "@/components/ui/alert"
+
+const quotationItemSchema = z.object({
+ rfqPrItemId: z.number(),
+ unitPrice: z.number().min(0),
+ totalPrice: z.number().min(0),
+ vendorDeliveryDate: z.date().optional().nullable(),
+ leadTime: z.number().optional(),
+ manufacturer: z.string().optional(),
+ manufacturerCountry: z.string().optional(),
+ modelNo: z.string().optional(),
+ technicalCompliance: z.boolean(),
+ alternativeProposal: z.string().optional(),
+ discountRate: z.number().optional(),
+ itemRemark: z.string().optional(),
+ deviationReason: z.string().optional(),
+}).passthrough(); // ⬅️ 여기가 핵심: 정의 안 된 키도 유지
// 폼 스키마 정의
const vendorResponseSchema = z.object({
// 상업 조건
@@ -59,21 +75,7 @@ const vendorResponseSchema = z.object({
technicalProposal: z.string().optional(),
// 견적 아이템
- quotationItems: z.array(z.object({
- rfqPrItemId: z.number(),
- unitPrice: z.number().min(0),
- totalPrice: z.number().min(0),
- vendorDeliveryDate: z.date().optional().nullable(),
- leadTime: z.number().optional(),
- manufacturer: z.string().optional(),
- manufacturerCountry: z.string().optional(),
- modelNo: z.string().optional(),
- technicalCompliance: z.boolean(),
- alternativeProposal: z.string().optional(),
- discountRate: z.number().optional(),
- itemRemark: z.string().optional(),
- deviationReason: z.string().optional(),
- }))
+quotationItems: z.array(quotationItemSchema),
})
type VendorResponseFormData = z.infer<typeof vendorResponseSchema>
@@ -104,6 +106,8 @@ export default function VendorResponseEditor({
const [attachments, setAttachments] = useState<File[]>([])
const [uploadProgress, setUploadProgress] = useState(0) // 추가
+ console.log(existingResponse,"existingResponse")
+
// Form 초기값 설정
const defaultValues: VendorResponseFormData = {
@@ -175,6 +179,8 @@ export default function VendorResponseEditor({
}
}, [errors])
+ console.log(methods.getValues())
+
const handleFormSubmit = (isSubmit: boolean = false) => {
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 98d53f5d..ef906ed6 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -1206,7 +1206,7 @@ export function RfqVendorTable({
<Button
variant="ghost"
size="sm"
- onClick={() => handleAction("response-detail", row.original)}
+ onClick={() => handleAction("view", row.original)}
className="h-7 px-2"
>
<Eye className="h-3 w-3 mr-1" />
diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
index 54aada1d..17eed54c 100644
--- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx
+++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
@@ -148,8 +148,8 @@ export function VendorResponseDetailDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
+ <DialogContent className="max-w-5xl max-h-[90vh] p-0 flex flex-col">
+ <DialogHeader className="flex-shrink-0 sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b px-6 py-4">
<div className="flex items-center justify-between">
<div>
<DialogTitle className="text-xl font-bold">
@@ -170,7 +170,9 @@ export function VendorResponseDetailDialog({
</div>
</DialogHeader>
- <Tabs defaultValue="overview" className="mt-4">
+ <div className="flex-1 overflow-y-auto px-6 ">
+
+ <Tabs defaultValue="overview" className="mb-2">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">개요</TabsTrigger>
<TabsTrigger value="quotation">견적정보</TabsTrigger>
@@ -689,6 +691,8 @@ export function VendorResponseDetailDialog({
)}
</TabsContent>
</Tabs>
+ </div>
+
</DialogContent>
</Dialog>
);
diff --git a/lib/services/fileService.ts b/lib/services/fileService.ts
new file mode 100644
index 00000000..56966a86
--- /dev/null
+++ b/lib/services/fileService.ts
@@ -0,0 +1,516 @@
+// lib/services/fileService.ts
+import db from "@/db/db";
+import {
+ fileItems,
+ filePermissions,
+ fileShares,
+ fileActivityLogs,
+ projects,
+ type FileItem,
+ type NewFileItem,
+ type FilePermission,
+} from "@/db/schema/fileSystem";
+import { users } from "@/db/schema/users";
+import { eq, and, or, isNull, lte, gte, sql, inArray } from "drizzle-orm";
+import crypto from "crypto";
+
+export interface FileAccessContext {
+ userId: number;
+ userDomain: string;
+ userEmail: string;
+ ipAddress?: string;
+ userAgent?: string;
+}
+
+export class FileService {
+ // 사용자가 내부 사용자인지 확인
+ private isInternalUser(domain: string): boolean {
+ // partners가 아닌 경우 내부 사용자로 간주
+ return domain !== "partners";
+ }
+
+ // 파일 접근 권한 확인
+ async checkFileAccess(
+ fileId: string,
+ context: FileAccessContext,
+ requiredAction: "view" | "download" | "edit" | "delete" | "share"
+ ): Promise<boolean> {
+ // 내부 사용자는 모든 권한 보유
+ if (this.isInternalUser(context.userDomain)) {
+ return true;
+ }
+
+ // 파일 정보 조회
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, fileId),
+ });
+
+ if (!file) return false;
+
+ // 외부 사용자 권한 체크
+ // 1. 파일 카테고리별 기본 권한 체크
+ switch (file.category) {
+ case "public":
+ // public 파일은 열람과 다운로드 가능
+ if (requiredAction === "view" || requiredAction === "download") {
+ return true;
+ }
+ break;
+ case "restricted":
+ // restricted 파일은 열람만 가능
+ if (requiredAction === "view") {
+ return true;
+ }
+ break;
+ case "confidential":
+ case "internal":
+ // 기본적으로 접근 불가
+ break;
+ }
+
+ // 2. 개별 권한 설정 체크
+ const permission = await db.query.filePermissions.findFirst({
+ where: and(
+ eq(filePermissions.fileItemId, fileId),
+ or(
+ eq(filePermissions.userId, context.userId),
+ eq(filePermissions.userDomain, context.userDomain)
+ ),
+ or(
+ isNull(filePermissions.validFrom),
+ lte(filePermissions.validFrom, new Date())
+ ),
+ or(
+ isNull(filePermissions.validUntil),
+ gte(filePermissions.validUntil, new Date())
+ )
+ ),
+ });
+
+ if (permission) {
+ switch (requiredAction) {
+ case "view": return permission.canView;
+ case "download": return permission.canDownload;
+ case "edit": return permission.canEdit;
+ case "delete": return permission.canDelete;
+ case "share": return permission.canShare;
+ }
+ }
+
+ return false;
+ }
+
+ // 파일 목록 조회 (트리 뷰 지원)
+async getFileList(
+ projectId: string,
+ parentId: string | null,
+ context: FileAccessContext,
+ options?: {
+ includeAll?: boolean; // 전체 파일 가져오기 옵션
+ }
+) {
+ const isInternal = this.isInternalUser(context.userDomain);
+
+ // 기본 쿼리 빌드
+ let baseConditions = [eq(fileItems.projectId, projectId)];
+
+ // includeAll이 false이거나 명시되지 않은 경우에만 parentId 조건 추가
+ if (!options?.includeAll) {
+ baseConditions.push(
+ parentId ? eq(fileItems.parentId, parentId) : isNull(fileItems.parentId)
+ );
+ }
+
+ let query = db
+ .select({
+ file: fileItems,
+ canView: sql<boolean>`true`,
+ canDownload: sql<boolean>`${isInternal}`,
+ canEdit: sql<boolean>`${isInternal}`,
+ canDelete: sql<boolean>`${isInternal}`,
+ })
+ .from(fileItems)
+ .where(and(...baseConditions));
+
+ if (!isInternal) {
+ // 외부 사용자는 접근 가능한 파일만 표시
+ let externalConditions = [eq(fileItems.projectId, projectId)];
+
+ if (!options?.includeAll) {
+ externalConditions.push(
+ parentId ? eq(fileItems.parentId, parentId) : isNull(fileItems.parentId)
+ );
+ }
+
+ query = db
+ .select({
+ file: fileItems,
+ canView: sql<boolean>`
+ CASE
+ WHEN ${fileItems.category} IN ('public', 'restricted') THEN true
+ WHEN ${filePermissions.canView} = true THEN true
+ ELSE false
+ END
+ `,
+ canDownload: sql<boolean>`
+ CASE
+ WHEN ${fileItems.category} = 'public' THEN true
+ WHEN ${filePermissions.canDownload} = true THEN true
+ ELSE false
+ END
+ `,
+ canEdit: sql<boolean>`COALESCE(${filePermissions.canEdit}, false)`,
+ canDelete: sql<boolean>`COALESCE(${filePermissions.canDelete}, false)`,
+ })
+ .from(fileItems)
+ .leftJoin(
+ filePermissions,
+ and(
+ eq(filePermissions.fileItemId, fileItems.id),
+ or(
+ eq(filePermissions.userId, context.userId),
+ eq(filePermissions.userDomain, context.userDomain)
+ )
+ )
+ )
+ .where(
+ and(
+ ...externalConditions,
+ or(
+ inArray(fileItems.category, ["public", "restricted"]),
+ eq(filePermissions.canView, true)
+ )
+ )
+ );
+ }
+
+ const results = await query;
+
+ // 활동 로그 기록 (전체 목록 조회시에는 로그 생략)
+ if (!options?.includeAll) {
+ for (const result of results) {
+ await this.logActivity(result.file.id, projectId, "view", context);
+ }
+ }
+
+ return results.map(r => ({
+ ...r.file,
+ permissions: {
+ canView: r.canView,
+ canDownload: r.canDownload,
+ canEdit: r.canEdit,
+ canDelete: r.canDelete,
+ },
+ }));
+}
+
+
+ // 파일/폴더 생성
+ async createFileItem(
+ data: NewFileItem,
+ context: FileAccessContext
+ ): Promise<FileItem> {
+ // 내부 사용자만 파일 생성 가능
+ if (!this.isInternalUser(context.userDomain)) {
+ throw new Error("권한이 없습니다");
+ }
+
+ // 경로 계산
+ let path = "/";
+ let depth = 0;
+
+ if (data.parentId) {
+ const parent = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, data.parentId),
+ });
+ if (parent) {
+ path = `${parent.path}${parent.name}/`;
+ depth = parent.depth + 1;
+ }
+ }
+
+ const [newFile] = await db
+ .insert(fileItems)
+ .values({
+ ...data,
+ path,
+ depth,
+ createdBy: context.userId,
+ updatedBy: context.userId,
+ })
+ .returning();
+
+ await this.logActivity(newFile.id, newFile.projectId, "upload", context);
+
+ return newFile;
+ }
+
+ // 파일 다운로드
+ async downloadFile(
+ fileId: string,
+ context: FileAccessContext
+ ): Promise<FileItem | null> {
+ const hasAccess = await this.checkFileAccess(fileId, context, "download");
+
+ if (!hasAccess) {
+ throw new Error("다운로드 권한이 없습니다");
+ }
+
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, fileId),
+ });
+
+ if (!file) return null;
+
+ // 다운로드 카운트 증가
+ await db
+ .update(fileItems)
+ .set({
+ downloadCount: sql`${fileItems.downloadCount} + 1`,
+ })
+ .where(eq(fileItems.id, fileId));
+
+ // 활동 로그 기록
+ await this.logActivity(fileId, file.projectId, "download", context);
+
+ return file;
+ }
+
+ // 파일 공유 링크 생성
+ async createShareLink(
+ fileId: string,
+ options: {
+ accessLevel?: "view_only" | "view_download";
+ password?: string;
+ expiresAt?: Date;
+ maxDownloads?: number;
+ sharedWithEmail?: string;
+ },
+ context: FileAccessContext
+ ): Promise<string> {
+ const hasAccess = await this.checkFileAccess(fileId, context, "share");
+
+ if (!hasAccess) {
+ throw new Error("공유 권한이 없습니다");
+ }
+
+ const shareToken = crypto.randomBytes(32).toString("hex");
+
+ const [share] = await db
+ .insert(fileShares)
+ .values({
+ fileItemId: fileId,
+ shareToken,
+ accessLevel: options.accessLevel || "view_only",
+ password: options.password,
+ expiresAt: options.expiresAt,
+ maxDownloads: options.maxDownloads,
+ sharedWithEmail: options.sharedWithEmail,
+ createdBy: context.userId,
+ })
+ .returning();
+
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, fileId),
+ });
+
+ if (file) {
+ await this.logActivity(fileId, file.projectId, "share", context, {
+ shareId: share.id,
+ sharedWithEmail: options.sharedWithEmail,
+ });
+ }
+
+ return shareToken;
+ }
+
+ // 공유 링크로 파일 접근
+ async accessFileByShareToken(
+ shareToken: string,
+ password?: string
+ ): Promise<{ file: FileItem; accessLevel: string } | null> {
+ const share = await db.query.fileShares.findFirst({
+ where: eq(fileShares.shareToken, shareToken),
+ with: {
+ fileItem: true,
+ },
+ });
+
+ if (!share || !share.fileItem) return null;
+
+ // 유효성 검사
+ if (share.expiresAt && share.expiresAt < new Date()) {
+ throw new Error("공유 링크가 만료되었습니다");
+ }
+
+ if (share.password && share.password !== password) {
+ throw new Error("비밀번호가 일치하지 않습니다");
+ }
+
+ if (
+ share.maxDownloads &&
+ share.currentDownloads >= share.maxDownloads
+ ) {
+ throw new Error("최대 다운로드 횟수를 초과했습니다");
+ }
+
+ // 접근 기록 업데이트
+ await db
+ .update(fileShares)
+ .set({
+ lastAccessedAt: new Date(),
+ })
+ .where(eq(fileShares.id, share.id));
+
+ // 조회수 증가
+ await db
+ .update(fileItems)
+ .set({
+ viewCount: sql`${fileItems.viewCount} + 1`,
+ })
+ .where(eq(fileItems.id, share.fileItemId));
+
+ return {
+ file: share.fileItem,
+ accessLevel: share.accessLevel,
+ };
+ }
+
+ // 파일 권한 부여
+ async grantPermission(
+ fileId: string,
+ targetUserId: number | null,
+ targetDomain: string | null,
+ permissions: {
+ canView?: boolean;
+ canDownload?: boolean;
+ canEdit?: boolean;
+ canDelete?: boolean;
+ canShare?: boolean;
+ },
+ context: FileAccessContext
+ ): Promise<void> {
+ // 내부 사용자만 권한 부여 가능
+ if (!this.isInternalUser(context.userDomain)) {
+ throw new Error("권한 부여 권한이 없습니다");
+ }
+
+ await db
+ .insert(filePermissions)
+ .values({
+ fileItemId: fileId,
+ userId: targetUserId,
+ userDomain: targetDomain,
+ ...permissions,
+ grantedBy: context.userId,
+ })
+ .onConflictDoUpdate({
+ target: [filePermissions.fileItemId, filePermissions.userId],
+ set: {
+ ...permissions,
+ updatedAt: new Date(),
+ },
+ });
+ }
+
+ // 활동 로그 기록
+ private async logActivity(
+ fileItemId: string,
+ projectId: string,
+ action: string,
+ context: FileAccessContext,
+ details: any = {}
+ ): Promise<void> {
+ await db.insert(fileActivityLogs).values({
+ fileItemId,
+ projectId,
+ action,
+ actionDetails: details,
+ userId: context.userId,
+ userEmail: context.userEmail,
+ userDomain: context.userDomain,
+ ipAddress: context.ipAddress,
+ userAgent: context.userAgent,
+ });
+ }
+
+ // 파일 이동
+ async moveFile(
+ fileId: string,
+ newParentId: string | null,
+ context: FileAccessContext
+ ): Promise<void> {
+ const hasAccess = await this.checkFileAccess(fileId, context, "edit");
+
+ if (!hasAccess) {
+ throw new Error("이동 권한이 없습니다");
+ }
+
+ // 새 경로 계산
+ let newPath = "/";
+ let newDepth = 0;
+
+ if (newParentId) {
+ const newParent = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, newParentId),
+ });
+ if (newParent) {
+ newPath = `${newParent.path}${newParent.name}/`;
+ newDepth = newParent.depth + 1;
+ }
+ }
+
+ await db
+ .update(fileItems)
+ .set({
+ parentId: newParentId,
+ path: newPath,
+ depth: newDepth,
+ updatedBy: context.userId,
+ updatedAt: new Date(),
+ })
+ .where(eq(fileItems.id, fileId));
+
+ // 하위 항목들의 경로도 재귀적으로 업데이트 필요 (생략)
+ }
+
+ // 파일 삭제
+ async deleteFile(
+ fileId: string,
+ context: FileAccessContext
+ ): Promise<void> {
+ const hasAccess = await this.checkFileAccess(fileId, context, "delete");
+
+ if (!hasAccess) {
+ throw new Error("삭제 권한이 없습니다");
+ }
+
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, fileId),
+ });
+
+ if (file) {
+ await this.logActivity(fileId, file.projectId, "delete", context);
+ }
+
+ await db.delete(fileItems).where(eq(fileItems.id, fileId));
+ }
+
+ // 프로젝트별 스토리지 사용량 계산
+ async getProjectStorageUsage(projectId: string): Promise<{
+ totalSize: number;
+ fileCount: number;
+ folderCount: number;
+ }> {
+ const result = await db
+ .select({
+ totalSize: sql<number>`COALESCE(SUM(${fileItems.size}), 0)`,
+ fileCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'file' THEN 1 END)`,
+ folderCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'folder' THEN 1 END)`,
+ })
+ .from(fileItems)
+ .where(eq(fileItems.projectId, projectId));
+
+ return result[0] || { totalSize: 0, fileCount: 0, folderCount: 0 };
+ }
+} \ No newline at end of file
diff --git a/lib/services/projectService.ts b/lib/services/projectService.ts
new file mode 100644
index 00000000..55ddcf0e
--- /dev/null
+++ b/lib/services/projectService.ts
@@ -0,0 +1,471 @@
+// lib/services/projectService.ts
+import db from "@/db/db";
+import {
+ fileSystemProjects,
+ fileItems,
+ projectMembers,
+ fileActivityLogs,
+ type FileSystemProject,
+ type NewFileSystemProject,
+} from "@/db/schema/fileSystem";
+import { users } from "@/db/schema/users";
+import { eq, and, or, inArray, gte, sql, not } from "drizzle-orm";
+
+// 프로젝트 멤버 역할 타입
+export type ProjectRole = "owner" | "admin" | "editor" | "viewer";
+
+export class ProjectService {
+ // 프로젝트 생성 (생성자가 자동으로 owner가 됨)
+ async createProject(
+ data: {
+ name: string;
+ description?: string;
+ isPublic?: boolean;
+ },
+ userId: number
+ ): Promise<FileSystemProject> {
+ const [project] = await db.transaction(async (tx) => {
+ // 1. 프로젝트 생성
+ const [newProject] = await tx
+ .insert(fileSystemProjects)
+ .values({
+ ...data,
+ ownerId: userId,
+ })
+ .returning();
+
+ // 2. 생성자를 owner로 프로젝트 멤버에 추가
+ await tx.insert(projectMembers).values({
+ projectId: newProject.id,
+ userId: userId,
+ role: "owner",
+ addedBy: userId,
+ });
+
+ return [newProject];
+ });
+
+ return project;
+ }
+
+ // 프로젝트 Owner 확인
+ async isProjectOwner(projectId: string, userId: number): Promise<boolean> {
+ const project = await db.query.fileSystemProjects.findFirst({
+ where: and(
+ eq(fileSystemProjects.id, projectId),
+ eq(fileSystemProjects.ownerId, userId)
+ ),
+ });
+
+ return !!project;
+ }
+
+ // 프로젝트 접근 권한 확인
+ async checkProjectAccess(
+ projectId: string,
+ userId: number,
+ requiredRole?: ProjectRole
+ ): Promise<{
+ hasAccess: boolean;
+ role?: ProjectRole;
+ isOwner: boolean;
+ }> {
+ // 1. Owner 확인
+ const project = await db.query.fileSystemProjects.findFirst({
+ where: eq(fileSystemProjects.id, projectId),
+ });
+
+ if (!project) {
+ return { hasAccess: false, isOwner: false };
+ }
+
+ const isOwner = project.ownerId === userId;
+
+ // Owner는 모든 권한 보유
+ if (isOwner) {
+ return { hasAccess: true, role: "owner", isOwner: true };
+ }
+
+ // 2. 프로젝트 멤버 확인
+ const member = await db.query.projectMembers.findFirst({
+ where: and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.userId, userId)
+ ),
+ });
+
+ if (!member) {
+ // 공개 프로젝트인 경우 viewer 권한
+ if (project.isPublic) {
+ return {
+ hasAccess: !requiredRole || requiredRole === "viewer",
+ role: "viewer",
+ isOwner: false
+ };
+ }
+ return { hasAccess: false, isOwner: false };
+ }
+
+ // 3. 역할 계층 확인
+ const roleHierarchy: Record<ProjectRole, number> = {
+ owner: 4,
+ admin: 3,
+ editor: 2,
+ viewer: 1,
+ };
+
+ const hasRequiredRole = !requiredRole ||
+ roleHierarchy[member.role] >= roleHierarchy[requiredRole];
+
+ return {
+ hasAccess: hasRequiredRole,
+ role: member.role as ProjectRole,
+ isOwner: false,
+ };
+ }
+
+ // 프로젝트 멤버 추가 (Owner만 가능)
+ async addProjectMember(
+ projectId: string,
+ newMemberId: number,
+ role: ProjectRole,
+ addedByUserId: number
+ ): Promise<void> {
+ // Owner 권한 확인
+ const isOwner = await this.isProjectOwner(projectId, addedByUserId);
+
+ if (!isOwner) {
+ throw new Error("프로젝트 소유자만 멤버를 추가할 수 있습니다");
+ }
+
+ // Owner 역할은 양도를 통해서만 가능
+ if (role === "owner") {
+ throw new Error("Owner 역할은 직접 할당할 수 없습니다. transferOwnership을 사용하세요.");
+ }
+
+ await db.insert(projectMembers).values({
+ projectId,
+ userId: newMemberId,
+ role,
+ addedBy: addedByUserId,
+ });
+ }
+
+ // 프로젝트 소유권 이전 (Owner만 가능)
+ async transferOwnership(
+ projectId: string,
+ currentOwnerId: number,
+ newOwnerId: number
+ ): Promise<void> {
+ await db.transaction(async (tx) => {
+ // 1. 현재 Owner 확인
+ const project = await tx.query.fileSystemProjects.findFirst({
+ where: and(
+ eq(fileSystemProjects.id, projectId),
+ eq(fileSystemProjects.ownerId, currentOwnerId)
+ ),
+ });
+
+ if (!project) {
+ throw new Error("프로젝트 소유자만 소유권을 이전할 수 있습니다");
+ }
+
+ // 2. 프로젝트 owner 업데이트
+ await tx
+ .update(fileSystemProjects)
+ .set({ ownerId: newOwnerId })
+ .where(eq(fileSystemProjects.id, projectId));
+
+ // 3. 프로젝트 멤버 역할 업데이트
+ // 이전 owner를 admin으로 변경
+ await tx
+ .update(projectMembers)
+ .set({ role: "admin" })
+ .where(
+ and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.userId, currentOwnerId)
+ )
+ );
+
+ // 새 owner를 owner 역할로 설정 (없으면 추가)
+ await tx
+ .insert(projectMembers)
+ .values({
+ projectId,
+ userId: newOwnerId,
+ role: "owner",
+ addedBy: currentOwnerId,
+ })
+ .onConflictDoUpdate({
+ target: [projectMembers.projectId, projectMembers.userId],
+ set: { role: "owner", updatedAt: new Date() },
+ });
+ });
+ }
+
+ // 프로젝트 삭제 (Owner만 가능)
+ async deleteProject(projectId: string, userId: number): Promise<void> {
+ const isOwner = await this.isProjectOwner(projectId, userId);
+
+ if (!isOwner) {
+ throw new Error("프로젝트 소유자만 프로젝트를 삭제할 수 있습니다");
+ }
+
+ // 프로젝트 삭제 (cascade로 관련 파일, 멤버 등도 삭제됨)
+ await db.delete(fileSystemProjects).where(eq(fileSystemProjects.id, projectId));
+ }
+
+ // 프로젝트 설정 변경 (Owner와 Admin만 가능)
+ async updateProjectSettings(
+ projectId: string,
+ userId: number,
+ settings: {
+ name?: string;
+ description?: string;
+ isPublic?: boolean;
+ externalAccessEnabled?: boolean;
+ }
+ ): Promise<void> {
+ const access = await this.checkProjectAccess(projectId, userId, "admin");
+
+ if (!access.hasAccess) {
+ throw new Error("프로젝트 설정을 변경할 권한이 없습니다");
+ }
+
+ await db
+ .update(fileSystemProjects)
+ .set({
+ ...settings,
+ updatedAt: new Date(),
+ })
+ .where(eq(fileSystemProjects.id, projectId));
+ }
+
+ // 사용자의 프로젝트 목록 조회
+ async getUserProjects(userId: number): Promise<{
+ owned: FileSystemProject[];
+ member: Array<FileSystemProject & { role: ProjectRole }>;
+ public: FileSystemProject[];
+ }> {
+ // 1. 소유한 프로젝트
+ const ownedProjects = await db.query.fileSystemProjects.findMany({
+ where: eq(fileSystemProjects.ownerId, userId),
+ orderBy: (fileSystemProjects, { desc }) => [desc(fileSystemProjects.createdAt)],
+ });
+
+ // 2. 멤버로 참여한 프로젝트
+ const memberProjects = await db
+ .select({
+ project: fileSystemProjects,
+ role: projectMembers.role,
+ })
+ .from(projectMembers)
+ .innerJoin(fileSystemProjects, eq(fileSystemProjects.id, projectMembers.projectId))
+ .where(
+ and(
+ eq(projectMembers.userId, userId),
+ // Owner가 아닌 경우만 (중복 방지) - not 사용
+ not(eq(fileSystemProjects.ownerId, userId))
+ )
+ );
+
+ // 3. 공개 프로젝트 (참여하지 않은)
+ const memberProjectIds = memberProjects.map(mp => mp.project.id);
+ const ownedProjectIds = ownedProjects.map(p => p.id);
+ const allUserProjectIds = [...memberProjectIds, ...ownedProjectIds];
+
+ let publicProjects;
+ if (allUserProjectIds.length > 0) {
+ publicProjects = await db.query.fileSystemProjects.findMany({
+ where: and(
+ eq(fileSystemProjects.isPublic, true),
+ not(eq(fileSystemProjects.ownerId, userId)),
+ not(inArray(fileSystemProjects.id, allUserProjectIds))
+ ),
+ orderBy: (fileSystemProjects, { desc }) => [desc(fileSystemProjects.createdAt)],
+ });
+ } else {
+ // 사용자가 참여한 프로젝트가 없는 경우
+ publicProjects = await db.query.fileSystemProjects.findMany({
+ where: and(
+ eq(fileSystemProjects.isPublic, true),
+ not(eq(fileSystemProjects.ownerId, userId))
+ ),
+ orderBy: (fileSystemProjects, { desc }) => [desc(fileSystemProjects.createdAt)],
+ });
+ }
+
+ return {
+ owned: ownedProjects,
+ member: memberProjects.map(mp => ({
+ ...mp.project,
+ role: mp.role as ProjectRole,
+ })),
+ public: publicProjects,
+ };
+ }
+
+ // 프로젝트 통계 (Owner용)
+ async getProjectStats(projectId: string, userId: number) {
+ const isOwner = await this.isProjectOwner(projectId, userId);
+
+ if (!isOwner) {
+ throw new Error("프로젝트 통계는 소유자만 볼 수 있습니다");
+ }
+
+ // 파일 통계
+ const fileStats = await db
+ .select({
+ totalFiles: sql<number>`COUNT(*)`,
+ totalSize: sql<number>`COALESCE(SUM(size), 0)`,
+ publicFiles: sql<number>`COUNT(CASE WHEN category = 'public' THEN 1 END)`,
+ restrictedFiles: sql<number>`COUNT(CASE WHEN category = 'restricted' THEN 1 END)`,
+ confidentialFiles: sql<number>`COUNT(CASE WHEN category = 'confidential' THEN 1 END)`,
+ })
+ .from(fileItems)
+ .where(eq(fileItems.projectId, projectId));
+
+ // 멤버 통계
+ const memberStats = await db
+ .select({
+ totalMembers: sql<number>`COUNT(*)`,
+ admins: sql<number>`COUNT(CASE WHEN role = 'admin' THEN 1 END)`,
+ editors: sql<number>`COUNT(CASE WHEN role = 'editor' THEN 1 END)`,
+ viewers: sql<number>`COUNT(CASE WHEN role = 'viewer' THEN 1 END)`,
+ })
+ .from(projectMembers)
+ .where(eq(projectMembers.projectId, projectId));
+
+ // 활동 통계 (최근 30일)
+ const activityStats = await db
+ .select({
+ totalViews: sql<number>`COUNT(CASE WHEN action = 'view' THEN 1 END)`,
+ totalDownloads: sql<number>`COUNT(CASE WHEN action = 'download' THEN 1 END)`,
+ totalUploads: sql<number>`COUNT(CASE WHEN action = 'upload' THEN 1 END)`,
+ uniqueUsers: sql<number>`COUNT(DISTINCT user_id)`,
+ })
+ .from(fileActivityLogs)
+ .where(
+ and(
+ eq(fileActivityLogs.projectId, projectId),
+ gte(fileActivityLogs.createdAt, new Date(Date.now() - 30 * 24 * 60 * 60 * 1000))
+ )
+ );
+
+ return {
+ files: fileStats[0],
+ members: memberStats[0],
+ activity: activityStats[0],
+ };
+ }
+
+ // 개별 프로젝트 정보 조회
+ async getProject(projectId: string): Promise<FileSystemProject | null> {
+ const project = await db.query.fileSystemProjects.findFirst({
+ where: eq(fileSystemProjects.id, projectId),
+ with: {
+ owner: true,
+ },
+ });
+
+ return project || null;
+ }
+
+ // 프로젝트 보관
+ async archiveProject(projectId: string, userId: number): Promise<void> {
+ const isOwner = await this.isProjectOwner(projectId, userId);
+
+ if (!isOwner) {
+ throw new Error("프로젝트 소유자만 보관할 수 있습니다");
+ }
+
+ // 프로젝트를 보관 상태로 변경
+ await db
+ .update(fileSystemProjects)
+ .set({
+ metadata: sql`jsonb_set(metadata, '{archived}', 'true')`,
+ updatedAt: new Date(),
+ })
+ .where(eq(fileSystemProjects.id, projectId));
+ }
+
+ // 멤버 역할 업데이트
+ async updateMemberRole(
+ projectId: string,
+ memberId: string,
+ newRole: ProjectRole
+ ): Promise<void> {
+ // Owner 역할은 transferOwnership를 통해서만 가능
+ if (newRole === 'owner') {
+ throw new Error("Owner 역할은 소유권 이전을 통해서만 가능합니다");
+ }
+
+ await db
+ .update(projectMembers)
+ .set({
+ role: newRole,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.id, memberId)
+ )
+ );
+ }
+
+ // 프로젝트 멤버 제거
+ async removeMember(projectId: string, memberId: string): Promise<void> {
+ // Owner는 제거할 수 없음
+ const member = await db.query.projectMembers.findFirst({
+ where: and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.id, memberId)
+ ),
+ });
+
+ if (member?.role === 'owner') {
+ throw new Error("Owner는 제거할 수 없습니다");
+ }
+
+ await db
+ .delete(projectMembers)
+ .where(
+ and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.id, memberId)
+ )
+ );
+ }
+
+ // 프로젝트 멤버 목록 조회
+ async getProjectMembers(projectId: string): Promise<any[]> {
+ const members = await db
+ .select({
+ id: projectMembers.id,
+ userId: projectMembers.userId,
+ role: projectMembers.role,
+ addedAt: projectMembers.createdAt,
+ user: {
+ name: users.name,
+ email: users.email,
+ imageUrl: users.imageUrl,
+ domain: users.domain,
+ },
+ })
+ .from(projectMembers)
+ .innerJoin(users, eq(users.id, projectMembers.userId))
+ .where(eq(projectMembers.projectId, projectId))
+ .orderBy(
+ sql`CASE
+ WHEN ${projectMembers.role} = 'owner' THEN 1
+ WHEN ${projectMembers.role} = 'admin' THEN 2
+ WHEN ${projectMembers.role} = 'editor' THEN 3
+ ELSE 4
+ END`
+ );
+
+ return members;
+ }
+}
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index 4c1861b9..14035562 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -429,7 +429,7 @@ React.useEffect(() => {
{/* Document Number Preview */}
<div className="mt-3 p-2 bg-white dark:bg-gray-900 border rounded">
<Label className="text-xs text-gray-600 dark:text-gray-400">
- {activeTab === "SHI" ? "Document Number" : "Vendor Document Number"} Preview:
+ {activeTab === "SHI" ? "Document Number" : "Project Document Number"} Preview:
</Label>
<div className="font-mono text-sm font-medium text-blue-600 dark:text-blue-400">
{generatePreviewDocNumber()}
@@ -525,85 +525,96 @@ React.useEffect(() => {
)
}
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
- <DialogHeader className="flex-shrink-0">
- <DialogTitle>Add New Document</DialogTitle>
- <DialogDescription>
- Enter the basic information for the new document.
- </DialogDescription>
- </DialogHeader>
+ return (
+<Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col overflow-hidden">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>Add New Document</DialogTitle>
+ <DialogDescription>
+ Enter the basic information for the new document.
+ </DialogDescription>
+ </DialogHeader>
+
+ {!shiType && !cpyType ? (
+ <div className="flex-1 flex items-center justify-center">
+ <Alert className="max-w-md">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ Required Document Number Type (SHI, CPY) is not configured. Please configure it first in the Number Types management.
+ </AlertDescription>
+ </Alert>
+ </div>
+ ) : (
+ <>
+ <Tabs
+ value={activeTab}
+ onValueChange={(v) => handleTabChange(v as "SHI" | "CPY")}
+ className="flex-1 min-h-0 flex flex-col"
+ >
+ {/* 고정 영역 */}
+ <TabsList className="grid w-full grid-cols-2 flex-shrink-0">
+ <TabsTrigger value="SHI" disabled={!shiType}>
+ SHI (Document No.)
+ {!shiType && <AlertTriangle className="ml-2 h-3 w-3" />}
+ </TabsTrigger>
+ <TabsTrigger value="CPY" disabled={!cpyType}>
+ CPY (Project Document No.)
+ {!cpyType && <AlertTriangle className="ml-2 h-3 w-3" />}
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 스크롤 영역 */}
+ <div className="flex-1 min-h-0 mt-4 overflow-y-auto pr-2">
+ <TabsContent
+ value="SHI"
+ className="data-[state=inactive]:hidden"
+ >
+ {shiType ? (
+ <DocumentForm />
+ ) : (
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ SHI Document Number Type is not configured.
+ </AlertDescription>
+ </Alert>
+ )}
+ </TabsContent>
- {!shiType && !cpyType ? (
- <div className="flex-1 flex items-center justify-center">
- <Alert className="max-w-md">
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- 필수 Document Number Type (SHI, CPY)이 설정되지 않았습니다.
- 먼저 Number Types 관리에서 설정해주세요.
- </AlertDescription>
- </Alert>
+ <TabsContent
+ value="CPY"
+ className="data-[state=inactive]:hidden"
+ >
+ {cpyType ? (
+ <DocumentForm />
+ ) : (
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ CPY Document Number Type is not configured.
+ </AlertDescription>
+ </Alert>
+ )}
+ </TabsContent>
</div>
- ) : (
- <>
- <Tabs value={activeTab} onValueChange={(v) => handleTabChange(v as "SHI" | "CPY")} className="flex-1 flex flex-col">
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="SHI" disabled={!shiType}>
- SHI (삼성중공업 도서번호)
- {!shiType && <AlertTriangle className="ml-2 h-3 w-3" />}
- </TabsTrigger>
- <TabsTrigger value="CPY" disabled={!cpyType}>
- CPY (프로젝트 문서번호)
- {!cpyType && <AlertTriangle className="ml-2 h-3 w-3" />}
- </TabsTrigger>
- </TabsList>
-
- <div className="flex-1 overflow-y-auto pr-2 mt-4">
- <TabsContent value="SHI" className="mt-0">
- {shiType ? (
- <DocumentForm />
- ) : (
- <Alert>
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- SHI Document Number Type이 설정되지 않았습니다.
- </AlertDescription>
- </Alert>
- )}
- </TabsContent>
-
- <TabsContent value="CPY" className="mt-0">
- {cpyType ? (
- <DocumentForm />
- ) : (
- <Alert>
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- CPY Document Number Type이 설정되지 않았습니다.
- </AlertDescription>
- </Alert>
- )}
- </TabsContent>
- </div>
- </Tabs>
+ </Tabs>
- <DialogFooter className="flex-shrink-0">
- <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
- Cancel
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isSubmitting || !isFormValid() || (!shiType && !cpyType)}
- >
- {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
- Add Document
- </Button>
- </DialogFooter>
- </>
- )}
- </DialogContent>
- </Dialog>
+ <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4">
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
+ Cancel
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isSubmitting || !isFormValid() || (!shiType && !cpyType)}
+ >
+ {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
+ Add Document
+ </Button>
+ </DialogFooter>
+ </>
+ )}
+ </DialogContent>
+</Dialog>
)
}
// =============================================================================
@@ -736,12 +747,12 @@ export function EditDocumentDialog({
{/* Vendor Document Number (Plant project only) */}
{isPlantProject && (
<div className="grid gap-2">
- <Label htmlFor="edit-vendorDocNumber">Vendor Document Number</Label>
+ <Label htmlFor="edit-vendorDocNumber">Project Document Number</Label>
<Input
id="edit-vendorDocNumber"
value={formData.vendorDocNumber}
onChange={(e) => setFormData({ ...formData, vendorDocNumber: e.target.value })}
- placeholder="Vendor provided document number"
+ placeholder="Project provided document number"
/>
</div>
)}
diff --git a/middleware.ts b/middleware.ts
index 595f87e1..c53c8455 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -17,6 +17,7 @@ const publicPaths = [
'/engineering',
'/partners',
'/privacy',
+ '/projects',
'/partners/repository',
'/partners/signup',
'/partners/tech-signup',
diff --git a/package-lock.json b/package-lock.json
index cd13fae3..f6836f3b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -106,6 +106,7 @@
"@types/docusign-esign": "^5.19.8",
"@types/formidable": "^3.4.5",
"accept-language": "^3.0.20",
+ "archiver": "^7.0.1",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -6631,6 +6632,18 @@
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC"
},
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
"node_modules/accept-language": {
"version": "3.0.20",
"resolved": "https://registry.npmjs.org/accept-language/-/accept-language-3.0.20.tgz",
@@ -6791,78 +6804,124 @@
"license": "ISC"
},
"node_modules/archiver": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
- "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
+ "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==",
"license": "MIT",
"dependencies": {
- "archiver-utils": "^2.1.0",
+ "archiver-utils": "^5.0.2",
"async": "^3.2.4",
- "buffer-crc32": "^0.2.1",
- "readable-stream": "^3.6.0",
+ "buffer-crc32": "^1.0.0",
+ "readable-stream": "^4.0.0",
"readdir-glob": "^1.1.2",
- "tar-stream": "^2.2.0",
- "zip-stream": "^4.1.0"
+ "tar-stream": "^3.0.0",
+ "zip-stream": "^6.0.1"
},
"engines": {
- "node": ">= 10"
+ "node": ">= 14"
}
},
"node_modules/archiver-utils": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
- "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz",
+ "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==",
"license": "MIT",
"dependencies": {
- "glob": "^7.1.4",
+ "glob": "^10.0.0",
"graceful-fs": "^4.2.0",
+ "is-stream": "^2.0.1",
"lazystream": "^1.0.0",
- "lodash.defaults": "^4.2.0",
- "lodash.difference": "^4.5.0",
- "lodash.flatten": "^4.4.0",
- "lodash.isplainobject": "^4.0.6",
- "lodash.union": "^4.6.0",
+ "lodash": "^4.17.15",
"normalize-path": "^3.0.0",
- "readable-stream": "^2.0.0"
+ "readable-stream": "^4.0.0"
},
"engines": {
- "node": ">= 6"
+ "node": ">= 14"
}
},
- "node_modules/archiver-utils/node_modules/isarray": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
- "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
- "license": "MIT"
+ "node_modules/archiver-utils/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/archiver-utils/node_modules/glob": {
+ "version": "10.4.5",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/archiver-utils/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/archiver-utils/node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
},
"node_modules/archiver-utils/node_modules/readable-stream": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
- "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
- "core-util-is": "~1.0.0",
- "inherits": "~2.0.3",
- "isarray": "~1.0.0",
- "process-nextick-args": "~2.0.0",
- "safe-buffer": "~5.1.1",
- "string_decoder": "~1.1.1",
- "util-deprecate": "~1.0.1"
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
- "node_modules/archiver-utils/node_modules/safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
- "license": "MIT"
- },
- "node_modules/archiver-utils/node_modules/string_decoder": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "node_modules/archiver/node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
- "safe-buffer": "~5.1.0"
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/are-we-there-yet": {
@@ -7228,6 +7287,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
+ "node_modules/bare-events": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz",
+ "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==",
+ "license": "Apache-2.0"
+ },
"node_modules/base64-arraybuffer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.2.tgz",
@@ -7361,6 +7426,30 @@
"readable-stream": "^3.4.0"
}
},
+ "node_modules/bl/node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
"node_modules/blob": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/blob/-/blob-0.0.2.tgz",
@@ -7434,9 +7523,9 @@
}
},
"node_modules/buffer": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
@@ -7454,16 +7543,16 @@
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
- "ieee754": "^1.1.13"
+ "ieee754": "^1.2.1"
}
},
"node_modules/buffer-crc32": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
- "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+ "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
"license": "MIT",
"engines": {
- "node": "*"
+ "node": ">=8.0.0"
}
},
"node_modules/buffer-equal-constant-time": {
@@ -7851,18 +7940,35 @@
"integrity": "sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA=="
},
"node_modules/compress-commons": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz",
- "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
+ "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==",
"license": "MIT",
"dependencies": {
- "buffer-crc32": "^0.2.13",
- "crc32-stream": "^4.0.2",
+ "crc-32": "^1.2.0",
+ "crc32-stream": "^6.0.0",
+ "is-stream": "^2.0.1",
"normalize-path": "^3.0.0",
- "readable-stream": "^3.6.0"
+ "readable-stream": "^4.0.0"
},
"engines": {
- "node": ">= 10"
+ "node": ">= 14"
+ }
+ },
+ "node_modules/compress-commons/node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/concat-map": {
@@ -7987,16 +8093,32 @@
}
},
"node_modules/crc32-stream": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz",
- "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz",
+ "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
- "readable-stream": "^3.4.0"
+ "readable-stream": "^4.0.0"
},
"engines": {
- "node": ">= 10"
+ "node": ">= 14"
+ }
+ },
+ "node_modules/crc32-stream/node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/create-require": {
@@ -9675,12 +9797,39 @@
"node": ">=0.10.0"
}
},
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/events-universal": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
+ "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-events": "^2.7.0"
+ }
+ },
"node_modules/exceljs": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
@@ -9701,6 +9850,134 @@
"node": ">=8.3.0"
}
},
+ "node_modules/exceljs/node_modules/archiver": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
+ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
+ "license": "MIT",
+ "dependencies": {
+ "archiver-utils": "^2.1.0",
+ "async": "^3.2.4",
+ "buffer-crc32": "^0.2.1",
+ "readable-stream": "^3.6.0",
+ "readdir-glob": "^1.1.2",
+ "tar-stream": "^2.2.0",
+ "zip-stream": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/exceljs/node_modules/archiver-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
+ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
+ "license": "MIT",
+ "dependencies": {
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.2.0",
+ "lazystream": "^1.0.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.difference": "^4.5.0",
+ "lodash.flatten": "^4.4.0",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.union": "^4.6.0",
+ "normalize-path": "^3.0.0",
+ "readable-stream": "^2.0.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/exceljs/node_modules/archiver-utils/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/exceljs/node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/exceljs/node_modules/compress-commons": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz",
+ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "^0.2.13",
+ "crc32-stream": "^4.0.2",
+ "normalize-path": "^3.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/exceljs/node_modules/crc32-stream": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz",
+ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
+ "license": "MIT",
+ "dependencies": {
+ "crc-32": "^1.2.0",
+ "readable-stream": "^3.4.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/exceljs/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/exceljs/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/exceljs/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/exceljs/node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/exceljs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
@@ -9710,6 +9987,41 @@
"uuid": "dist/bin/uuid"
}
},
+ "node_modules/exceljs/node_modules/zip-stream": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
+ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "archiver-utils": "^3.0.4",
+ "compress-commons": "^4.1.2",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/exceljs/node_modules/zip-stream/node_modules/archiver-utils": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz",
+ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
+ "license": "MIT",
+ "dependencies": {
+ "glob": "^7.2.3",
+ "graceful-fs": "^4.2.0",
+ "lazystream": "^1.0.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.difference": "^4.5.0",
+ "lodash.flatten": "^4.4.0",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.union": "^4.6.0",
+ "normalize-path": "^3.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/express": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.6.1.tgz",
@@ -9853,6 +10165,12 @@
"node": ">=6.0.0"
}
},
+ "node_modules/fast-fifo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+ "license": "MIT"
+ },
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -11363,6 +11681,18 @@
],
"license": "MIT"
},
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-string": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
@@ -14197,6 +14527,15 @@
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -15833,6 +16172,17 @@
"node": ">=10.0.0"
}
},
+ "node_modules/streamx": {
+ "version": "2.23.0",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
+ "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
+ "license": "MIT",
+ "dependencies": {
+ "events-universal": "^1.0.0",
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
+ }
+ },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -16323,19 +16673,28 @@
}
},
"node_modules/tar-stream": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
+ "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
"license": "MIT",
"dependencies": {
- "bl": "^4.0.3",
- "end-of-stream": "^1.4.1",
- "fs-constants": "^1.0.0",
- "inherits": "^2.0.3",
- "readable-stream": "^3.1.1"
+ "b4a": "^1.6.4",
+ "fast-fifo": "^1.2.0",
+ "streamx": "^2.15.0"
+ }
+ },
+ "node_modules/tar-stream/node_modules/b4a": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.2.tgz",
+ "integrity": "sha512-DyUOdz+E8R6+sruDpQNOaV0y/dBbV6X/8ZkxrDcR0Ifc3BgKlpgG0VAtfOozA0eMtJO5GGe9FsZhueLs00pTww==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native-b4a": "*"
},
- "engines": {
- "node": ">=6"
+ "peerDependenciesMeta": {
+ "react-native-b4a": {
+ "optional": true
+ }
}
},
"node_modules/tarn": {
@@ -16347,6 +16706,29 @@
"node": ">=8.0.0"
}
},
+ "node_modules/text-decoder": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
+ "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
+ "node_modules/text-decoder/node_modules/b4a": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.2.tgz",
+ "integrity": "sha512-DyUOdz+E8R6+sruDpQNOaV0y/dBbV6X/8ZkxrDcR0Ifc3BgKlpgG0VAtfOozA0eMtJO5GGe9FsZhueLs00pTww==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native-b4a": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-b4a": {
+ "optional": true
+ }
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -18078,38 +18460,33 @@
}
},
"node_modules/zip-stream": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
- "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
+ "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==",
"license": "MIT",
"dependencies": {
- "archiver-utils": "^3.0.4",
- "compress-commons": "^4.1.2",
- "readable-stream": "^3.6.0"
+ "archiver-utils": "^5.0.0",
+ "compress-commons": "^6.0.2",
+ "readable-stream": "^4.0.0"
},
"engines": {
- "node": ">= 10"
+ "node": ">= 14"
}
},
- "node_modules/zip-stream/node_modules/archiver-utils": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz",
- "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
+ "node_modules/zip-stream/node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
- "glob": "^7.2.3",
- "graceful-fs": "^4.2.0",
- "lazystream": "^1.0.0",
- "lodash.defaults": "^4.2.0",
- "lodash.difference": "^4.5.0",
- "lodash.flatten": "^4.4.0",
- "lodash.isplainobject": "^4.0.6",
- "lodash.union": "^4.6.0",
- "normalize-path": "^3.0.0",
- "readable-stream": "^3.6.0"
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
},
"engines": {
- "node": ">= 10"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/zod": {
diff --git a/package.json b/package.json
index 6c3aec93..391c89bd 100644
--- a/package.json
+++ b/package.json
@@ -109,6 +109,7 @@
"@types/docusign-esign": "^5.19.8",
"@types/formidable": "^3.4.5",
"accept-language": "^3.0.20",
+ "archiver": "^7.0.1",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",