From 4c2d4c235bd80368e31cae9c375e9a585f6a6844 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 25 Sep 2025 03:28:27 +0000 Subject: (대표님) archiver 추가, 데이터룸구현 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(evcp)/data-room/[projectId]/files/page.tsx | 14 + .../evcp/(evcp)/data-room/[projectId]/layout.tsx | 19 + .../(evcp)/data-room/[projectId]/members/page.tsx | 811 +++++++++++ .../evcp/(evcp)/data-room/[projectId]/page.tsx | 10 + .../(evcp)/data-room/[projectId]/settings/page.tsx | 488 +++++++ .../(evcp)/data-room/[projectId]/stats/page.tsx | 373 +++++ app/[lng]/evcp/(evcp)/data-room/page.tsx | 26 + .../data-room/[projectId]/files/page.tsx | 14 + .../(partners)/data-room/[projectId]/layout.tsx | 19 + .../data-room/[projectId]/members/page.tsx | 811 +++++++++++ .../(partners)/data-room/[projectId]/page.tsx | 10 + .../data-room/[projectId]/settings/page.tsx | 488 +++++++ .../data-room/[projectId]/stats/page.tsx | 373 +++++ app/[lng]/partners/(partners)/data-room/page.tsx | 26 + app/[lng]/shared/[token]/page.tsx | 15 + .../[projectId]/[fileId]/download/route.ts | 246 ++++ app/api/data-room/[projectId]/[fileId]/route.ts | 147 ++ .../download-folder/[folderId]/route.ts | 289 ++++ .../[projectId]/download-multiple/route.ts | 162 +++ app/api/data-room/[projectId]/permissions/route.ts | 74 + app/api/data-room/[projectId]/route.ts | 118 ++ .../data-room/[projectId]/share/[token]/route.ts | 45 + app/api/data-room/[projectId]/share/route.ts | 79 ++ app/api/data-room/[projectId]/upload/route.ts | 139 ++ app/api/files/[...path]/route.ts | 3 +- app/api/partners/rfq-last/[id]/response/route.ts | 6 +- app/api/projects/[projectId]/access/route.ts | 36 + .../[projectId]/members/[memberId]/route.ts | 89 ++ app/api/projects/[projectId]/members/route.ts | 76 + app/api/projects/[projectId]/route.ts | 134 ++ app/api/projects/[projectId]/stats/route.ts | 275 ++++ app/api/projects/route.ts | 56 + components/file-manager/FileManager.tsx | 1447 ++++++++++++++++++++ components/file-manager/SharedFileViewer.tsx | 411 ++++++ components/project/ProjectDashboard.tsx | 476 +++++++ components/project/ProjectHeader.tsx | 84 ++ components/project/ProjectList.tsx | 463 +++++++ components/project/ProjectNav.tsx | 149 ++ components/project/ProjectSidebar.tsx | 318 +++++ db/schema/fileSystem.ts | 377 +++++ db/schema/index.ts | 2 + lib/gtc-contract/service.ts | 1 + lib/itb/service.ts | 417 +++--- lib/rfq-last/service.ts | 6 +- .../editor/quotation-items-table.tsx | 2 + .../editor/vendor-response-editor.tsx | 36 +- lib/rfq-last/vendor/rfq-vendor-table.tsx | 2 +- lib/rfq-last/vendor/vendor-detail-dialog.tsx | 10 +- lib/services/fileService.ts | 516 +++++++ lib/services/projectService.ts | 471 +++++++ .../plant/document-stage-dialogs.tsx | 169 +-- middleware.ts | 1 + package-lock.json | 573 ++++++-- package.json | 1 + 54 files changed, 10996 insertions(+), 407 deletions(-) create mode 100644 app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx create mode 100644 app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/data-room/page.tsx create mode 100644 app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx create mode 100644 app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx create mode 100644 app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx create mode 100644 app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx create mode 100644 app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx create mode 100644 app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx create mode 100644 app/[lng]/partners/(partners)/data-room/page.tsx create mode 100644 app/[lng]/shared/[token]/page.tsx create mode 100644 app/api/data-room/[projectId]/[fileId]/download/route.ts create mode 100644 app/api/data-room/[projectId]/[fileId]/route.ts create mode 100644 app/api/data-room/[projectId]/download-folder/[folderId]/route.ts create mode 100644 app/api/data-room/[projectId]/download-multiple/route.ts create mode 100644 app/api/data-room/[projectId]/permissions/route.ts create mode 100644 app/api/data-room/[projectId]/route.ts create mode 100644 app/api/data-room/[projectId]/share/[token]/route.ts create mode 100644 app/api/data-room/[projectId]/share/route.ts create mode 100644 app/api/data-room/[projectId]/upload/route.ts create mode 100644 app/api/projects/[projectId]/access/route.ts create mode 100644 app/api/projects/[projectId]/members/[memberId]/route.ts create mode 100644 app/api/projects/[projectId]/members/route.ts create mode 100644 app/api/projects/[projectId]/route.ts create mode 100644 app/api/projects/[projectId]/stats/route.ts create mode 100644 app/api/projects/route.ts create mode 100644 components/file-manager/FileManager.tsx create mode 100644 components/file-manager/SharedFileViewer.tsx create mode 100644 components/project/ProjectDashboard.tsx create mode 100644 components/project/ProjectHeader.tsx create mode 100644 components/project/ProjectList.tsx create mode 100644 components/project/ProjectNav.tsx create mode 100644 components/project/ProjectSidebar.tsx create mode 100644 db/schema/fileSystem.ts create mode 100644 lib/services/fileService.ts create mode 100644 lib/services/projectService.ts 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 ( +
+ +
+ ); +} \ 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 ( +
+ +
+ {children} +
+
+ ); +} 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([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [roleFilter, setRoleFilter] = useState('all'); + const [addMemberOpen, setAddMemberOpen] = useState(false); + const [editingMember, setEditingMember] = useState(null); + + // 사용자 선택 관련 상태 + const [availableUsers, setAvailableUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [userSearchTerm, setUserSearchTerm] = useState(''); + const [userPopoverOpen, setUserPopoverOpen] = useState(false); + const [loadingUsers, setLoadingUsers] = useState(false); + const [isExternalUser, setIsExternalUser] = useState(false); // 외부 사용자 여부 + + const [newMemberRole, setNewMemberRole] = useState('viewer'); + const [currentUserRole, setCurrentUserRole] = useState('viewer'); + const [page, setPage] = useState(1); + const pageSize = 20; + + // Command component key management + const userOptionIdsRef = useRef>({}); + 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 ( +
+
+ +

멤버 목록을 불러오는 중...

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

프로젝트 멤버

+

+ 프로젝트에 참여 중인 멤버를 관리합니다 +

+
+ + {canManageMembers && ( + + )} +
+ + {/* 필터 */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+ + +
+ + {/* 멤버 목록 (Table) */} +
+ + + + + 이름 + 이메일 + 구분 + 역할 + 추가일 + 마지막 접속 + 액션 + + + + + {paginatedMembers.length > 0 ? ( + paginatedMembers.map((member) => { + const config = roleConfig[member.role]; + const Icon = config.icon; + const isInternal = member.user.domain !== 'partners'; + + return ( + + {/* Avatar */} + + + + + {member.user.name?.charAt(0).toUpperCase()} + + + + + {/* Name */} + + {member.user.name} + + + {/* Email */} + + {member.user.email} + + + {/* Domain */} + + + {isInternal ? 'Internal' : 'Partner'} + + + + {/* Role */} + + {canManageMembers && member.role !== 'owner' && member.user.domain !== 'partners' ? ( + + ) : ( +
+
+ + + {config.label} + +
+ {member.user.domain === 'partners' && canManageMembers && member.role !== 'owner' && ( + (고정) + )} +
+ )} +
+ + {/* AddedAt */} + + {formatDateShort(member.addedAt)} + + + {/* LastAccess */} + + {formatDateShort(member.lastAccess)} + + + {/* Actions */} + +
+ {canManageMembers && member.role !== 'owner' ? ( + + + + + + + + 메일 보내기 + + + removeMember(member.id)} + > + + 제거 + + + + ) : ( + + )} +
+
+
+ ); + }) + ) : ( + + +
+ + 검색 결과가 없습니다 +
+
+
+ )} +
+
+
+ + {/* Pagination */} +
+
+ 총 {filteredMembers.length}명 · {pageSize}명/페이지 +
+
+ + + {page} / {totalPages} + + +
+
+ + {/* 멤버 추가 다이얼로그 */} + + + + 멤버 추가 + + 프로젝트에 멤버를 추가합니다 + + + + + + 내부 사용자 + + 외부 사용자 + Viewer 전용 + + + + +
+ + + {loadingUsers ? ( +
+ + 사용자 목록 불러오는 중... +
+ ) : ( + <> + + + + + + + + { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + 사용자를 찾을 수 없습니다. + + {filteredUsers + .filter(u => u.domain !== 'partners') + .map((user) => ( + { + setSelectedUser(user); + setUserPopoverOpen(false); + setIsExternalUser(false); + setNewMemberRole('viewer'); + }} + value={`${user.name} ${user.email}`} + className="truncate" + > + +
+
{user.name}
+
{user.email}
+
+ +
+ ))} +
+
+
+
+
+ +

+ 내부 사용자는 모든 역할을 부여할 수 있습니다. +

+ + )} +
+ +
+ + +
+
+ + +
+

+ 보안 정책 안내
+ 외부 사용자(파트너)는 보안 정책상 Viewer 권한만 부여 가능합니다. +

+
+ +
+ + + {loadingUsers ? ( +
+ + 사용자 목록 불러오는 중... +
+ ) : ( + + + + + + + + { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + 파트너를 찾을 수 없습니다. + + {filteredUsers + .filter(u => u.domain === 'partners') + .map((user) => ( + { + setSelectedUser(user); + setUserPopoverOpen(false); + setIsExternalUser(true); + setNewMemberRole('viewer'); + }} + value={user.name} + className="truncate" + > + + {user.name} + 파트너 + + + ))} + + + + + + )} +
+ +
+ + +
+
+
+ + + + + +
+
+
+ ); +} \ 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 ; +} 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(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('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 ( +
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

프로젝트 설정

+

+ 프로젝트 설정을 관리합니다 +

+
+ + {canEdit && ( + + )} +
+ + {!canEdit && ( + + + + 프로젝트 설정을 변경하려면 Owner 또는 Admin 권한이 필요합니다. + + + )} + + + + 일반 + 접근 관리 + 스토리지 + {currentUserRole === 'owner' && ( + 위험 영역 + )} + + + + + + 기본 정보 + + +
+ + setSettings({ ...settings, name: e.target.value })} + disabled={!canEdit} + /> +
+ +
+ +