summaryrefslogtreecommitdiff
path: root/components/project/ProjectList.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-25 03:28:27 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-25 03:28:27 +0000
commit4c2d4c235bd80368e31cae9c375e9a585f6a6844 (patch)
tree7fd1847e1e30ef2052281453bfb7a1c45ac6627a /components/project/ProjectList.tsx
parentf69e125f1a0b47bbc22e2784208bf829bcdd24f8 (diff)
(대표님) archiver 추가, 데이터룸구현
Diffstat (limited to 'components/project/ProjectList.tsx')
-rw-r--r--components/project/ProjectList.tsx463
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