diff options
Diffstat (limited to 'components/project')
| -rw-r--r-- | components/project/ProjectDashboard.tsx | 476 | ||||
| -rw-r--r-- | components/project/ProjectHeader.tsx | 84 | ||||
| -rw-r--r-- | components/project/ProjectList.tsx | 463 | ||||
| -rw-r--r-- | components/project/ProjectNav.tsx | 149 | ||||
| -rw-r--r-- | components/project/ProjectSidebar.tsx | 318 |
5 files changed, 1490 insertions, 0 deletions
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> + ); +} + |
