diff options
Diffstat (limited to 'components/project/ProjectList.tsx')
| -rw-r--r-- | components/project/ProjectList.tsx | 463 |
1 files changed, 463 insertions, 0 deletions
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 |
