summaryrefslogtreecommitdiff
path: root/components/project
diff options
context:
space:
mode:
Diffstat (limited to 'components/project')
-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
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>
+ );
+}
+