diff --git a/frontend/src/components/AdminLayout/index.stories.tsx b/frontend/src/components/AdminLayout/index.stories.tsx new file mode 100644 index 0000000..9b60b9c --- /dev/null +++ b/frontend/src/components/AdminLayout/index.stories.tsx @@ -0,0 +1,44 @@ +import '@/index.css'; +import { Meta, StoryObj } from '@storybook/react'; +import AdminLayout from './index'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { Workspace } from '@/types'; + +const meta: Meta = { + title: 'Layout/AdminLayout', + component: AdminLayout, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +type Story = StoryObj; + +const workspace: Workspace = { + id: 1, + name: 'Workspace Alpha', + projects: [ + { + id: 1, + name: 'Project Alpha', + type: 'Segmentation', + children: [], + }, + { + id: 2, + name: 'Project Beta', + type: 'Classification', + children: [], + }, + ], +}; + +export const Default: Story = { + render: () => ( + + + + ), +}; diff --git a/frontend/src/components/AdminLayout/index.tsx b/frontend/src/components/AdminLayout/index.tsx new file mode 100644 index 0000000..39e24cb --- /dev/null +++ b/frontend/src/components/AdminLayout/index.tsx @@ -0,0 +1,32 @@ +import { Outlet } from 'react-router-dom'; +import Header from '../Header'; +import { ResizablePanelGroup, ResizablePanel } from '../ui/resizable'; +import AdminProjectSidebar from '../AdminProjectSidebar'; +import AdminMenuSidebar from '../AdminMenuSidebar'; +import { Workspace } from '@/types'; + +interface AdminLayoutProps { + workspace: Workspace; +} + +export default function AdminLayout({ workspace }: AdminLayoutProps) { + return ( + <> +
+
+ + + +
+ +
+
+ +
+
+ + ); +} diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx b/frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx new file mode 100644 index 0000000..b8abdff --- /dev/null +++ b/frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx @@ -0,0 +1,139 @@ +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form'; +import { Input } from '../ui/input'; +import { Button } from '../ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; + +type Role = 'admin' | 'editor' | 'viewer'; + +const roles: Role[] = ['admin', 'editor', 'viewer']; + +const roleToStr: { [key in Role]: string } = { + admin: '관리자', + editor: '에디터', + viewer: '뷰어', +}; + +const formSchema = z.object({ + members: z.array( + z.object({ + email: z.string().email({ message: '올바른 이메일 형식을 입력해주세요.' }), + role: z.enum(roles as [Role, ...Role[]], { errorMap: () => ({ message: '역할을 선택해주세요.' }) }), + }) + ), +}); + +export type MemberManageFormValues = z.infer; + +interface Member { + email: string; + role: Role; +} + +interface AdminMemberManageFormProps { + members: Member[]; + onSubmit: (data: MemberManageFormValues) => void; +} + +export default function AdminMemberManageForm({ members, onSubmit }: AdminMemberManageFormProps) { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { members }, + }); + + const groupedMembers = members.reduce<{ [key: string]: { email: string; role: Role }[] }>((acc, member) => { + if (!acc[member.role]) acc[member.role] = []; + acc[member.role].push(member); + return acc; + }, {}); + + const roleOrder: Role[] = ['admin', 'editor', 'viewer']; + + const sortedGroupedMembers = Object.entries(groupedMembers).sort( + ([roleA], [roleB]) => roleOrder.indexOf(roleA as Role) - roleOrder.indexOf(roleB as Role) + ); + + return ( +
+ +
+ {sortedGroupedMembers.map(([role, groupMembers]) => { + if (!groupMembers || groupMembers.length === 0) return null; + + return ( +
+ {roleToStr[role as Role]} + {groupMembers.map((member, index) => ( +
+ m.email === member.email)}.email`} + control={form.control} + render={({ field }) => ( + + + + + + + )} + /> + m.email === member.email)}.role`} + control={form.control} + render={({ field }) => ( + + + + + + + )} + /> +
+ ))} +
+ ); + })} +
+ +
+ + ); +} diff --git a/frontend/src/components/AdminMemberManage/index.stories.tsx b/frontend/src/components/AdminMemberManage/index.stories.tsx new file mode 100644 index 0000000..0c7d414 --- /dev/null +++ b/frontend/src/components/AdminMemberManage/index.stories.tsx @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from '@storybook/react'; +import AdminMemberManage from '.'; +import { MemberManageFormValues } from './AdminMemberManageForm'; + +const meta: Meta = { + title: 'Components/AdminMemberManage', + component: AdminMemberManage, + argTypes: { + title: { control: 'text' }, + members: { control: 'object' }, + projects: { control: 'object' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: '프로젝트 멤버 관리하기', + members: [ + { email: 'admin1@example.com', role: 'admin' }, + { email: 'admin2@example.com', role: 'admin' }, + { email: 'viewer3@example.com', role: 'viewer' }, + { email: 'editor1@example.com', role: 'editor' }, + { email: 'editor2@example.com', role: 'editor' }, + { email: 'editor3@example.com', role: 'editor' }, + { email: 'editor4@example.com', role: 'editor' }, + ], + projects: [ + { id: 'project-1', name: '프로젝트 A' }, + { id: 'project-2', name: '프로젝트 B' }, + { id: 'project-3', name: '프로젝트 C' }, + ], + onProjectChange: (projectId: string) => console.log('Selected Project:', projectId), + onMemberInvite: () => console.log('Invite member'), + onSubmit: (data: MemberManageFormValues) => console.log('Submitted:', data), + }, +}; diff --git a/frontend/src/components/AdminMemberManage/index.tsx b/frontend/src/components/AdminMemberManage/index.tsx new file mode 100644 index 0000000..4951792 --- /dev/null +++ b/frontend/src/components/AdminMemberManage/index.tsx @@ -0,0 +1,64 @@ +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; +import AdminMemberManageForm, { MemberManageFormValues } from './AdminMemberManageForm'; +import { Button } from '@/components/ui/button'; + +type Role = 'admin' | 'editor' | 'viewer'; + +interface Member { + email: string; + role: Role; +} + +interface Project { + id: string; + name: string; +} + +export default function AdminMemberManage({ + title = '멤버 관리', + projects, + onProjectChange, + onSubmit, + members, + onMemberInvite, +}: { + title?: string; + projects: Project[]; + onProjectChange: (projectId: string) => void; + onSubmit: (data: MemberManageFormValues) => void; + members: Member[]; + onMemberInvite: () => void; +}) { + return ( +
+
+

{title}

+ + +
+ +
+ ); +} diff --git a/frontend/src/components/AdminMenuSidebar/index.tsx b/frontend/src/components/AdminMenuSidebar/index.tsx new file mode 100644 index 0000000..e3fcac6 --- /dev/null +++ b/frontend/src/components/AdminMenuSidebar/index.tsx @@ -0,0 +1,36 @@ +import { useNavigate } from 'react-router-dom'; +import { cn } from '@/lib/utils'; + +export default function AdminMenuSidebar() { + const navigate = useNavigate(); + + const menuItems = [ + { label: '작업', path: '/admin/tasks' }, + { label: '리뷰', path: '/admin/reviews' }, + { label: '멤버 관리', path: '/admin/members' }, + ]; + + return ( +
+
+
+

메뉴

+
+
+ {menuItems.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/AdminProjectSidebar/index.tsx b/frontend/src/components/AdminProjectSidebar/index.tsx new file mode 100644 index 0000000..b48d3b7 --- /dev/null +++ b/frontend/src/components/AdminProjectSidebar/index.tsx @@ -0,0 +1,54 @@ +import { ResizablePanel, ResizableHandle } from '../ui/resizable'; +import { Project } from '@/types'; +import { useNavigate } from 'react-router-dom'; +import { SquarePen } from 'lucide-react'; +import { Button } from '../ui/button'; + +interface AdminProjectSidebarProps { + workspaceName: string; + projects: Project[]; +} + +export default function AdminProjectSidebar({ workspaceName, projects }: AdminProjectSidebarProps): JSX.Element { + const navigate = useNavigate(); + + return ( + <> + +
+

+ {workspaceName} +

+ + +
+
+ {projects.map((project) => ( + + ))} +
+
+ + + ); +} diff --git a/frontend/src/components/ReviewList/ReviewItem.tsx b/frontend/src/components/ReviewList/ReviewItem.tsx new file mode 100644 index 0000000..4784329 --- /dev/null +++ b/frontend/src/components/ReviewList/ReviewItem.tsx @@ -0,0 +1,55 @@ +import { Briefcase, Tag, Box, Layers, Pen } from 'lucide-react'; + +interface ReviewItemProps { + title: string; + createdTime: string; + creatorName: string; + project: string; + status: string; + type: { text: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; color: string }; +} + +const typeIcons: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', JSX.Element> = { + Classification: , + Detection: , + Polygon: , + Polyline: , +}; + +const typeStyles: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', string> = { + Classification: '#a2eeef', + Detection: '#d4c5f9', + Polygon: '#f9c5d4', + Polyline: '#c5f9d4', +}; + +export default function ReviewItem({ title, createdTime, creatorName, project, status, type }: ReviewItemProps) { + const icon = typeIcons[type.text]; + const bgColor = typeStyles[type.text]; + + return ( +
+
+

{title}

+

by {creatorName}

+
+ +

{project}

+
+ {type && ( +
+ {icon} + {type.text} +
+ )} +
+
+
{status}
+

Created at {createdTime}

+
+
+ ); +} diff --git a/frontend/src/components/ReviewQuest/ReviewSearchInput.tsx b/frontend/src/components/ReviewList/ReviewSearchInput.tsx similarity index 100% rename from frontend/src/components/ReviewQuest/ReviewSearchInput.tsx rename to frontend/src/components/ReviewList/ReviewSearchInput.tsx diff --git a/frontend/src/components/ReviewList/index.stories.tsx b/frontend/src/components/ReviewList/index.stories.tsx new file mode 100644 index 0000000..2e9d32d --- /dev/null +++ b/frontend/src/components/ReviewList/index.stories.tsx @@ -0,0 +1,63 @@ +import '@/index.css'; +import type { Meta, StoryObj } from '@storybook/react'; +import ReviewList from '.'; + +const meta: Meta = { + title: 'Components/ReviewList', + component: ReviewList, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + acceptedCount: 10, + rejectedCount: 5, + pendingCount: 7, + totalCount: 22, + items: [ + { + title: '갤럭시22 생산 라인 이물질 분류', + createdTime: '2 hours ago', + creatorName: 'Kim Tae Su', + project: 'Project A', + type: 'Classification', + status: 'needs_review', + }, + { + title: '갤럭시 흠집 객체 탐지', + createdTime: '3 hours ago', + creatorName: 'Kim Tae Su', + project: 'Project B', + type: 'Detection', + status: 'completed', + }, + { + title: '갤럭시 흠집 경계 폴리곤', + createdTime: '5 hours ago', + creatorName: 'Kim Tae Su', + project: 'Project C', + type: 'Polygon', + status: 'in_progress', + }, + { + title: '갤럭시 흠집 폴리라인', + createdTime: '1 day ago', + creatorName: 'Kim Tae Su', + project: 'Project D', + type: 'Polyline', + status: 'completed', + }, + { + title: '갤럭시 흠집 디텍션 허가 요청', + createdTime: '6 hours ago', + creatorName: 'Kim Tae Su', + project: 'Project E', + type: 'Detection', + status: 'pending', + }, + ], + }, +}; diff --git a/frontend/src/components/ReviewQuest/index.tsx b/frontend/src/components/ReviewList/index.tsx similarity index 87% rename from frontend/src/components/ReviewQuest/index.tsx rename to frontend/src/components/ReviewList/index.tsx index 80e3267..7abaf45 100644 --- a/frontend/src/components/ReviewQuest/index.tsx +++ b/frontend/src/components/ReviewList/index.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import ReviewRequestItem from './ReviewRequestItem'; +import ReviewItem from './ReviewItem'; import ReviewSearchInput from './ReviewSearchInput'; -interface ReviewRequestProps { +interface ReviewListProps { acceptedCount: number; rejectedCount: number; pendingCount: number; @@ -14,9 +14,6 @@ interface ReviewRequestProps { project: string; type: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; status: string; - commentsCount: number; - updatesCount: number; - lastUpdated: string; }[]; } @@ -27,13 +24,13 @@ const typeColors: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline' Polyline: '#c5f9d4', }; -export default function ReviewRequest({ +export default function ReviewList({ acceptedCount, rejectedCount, pendingCount, totalCount, items, -}: ReviewRequestProps): JSX.Element { +}: ReviewListProps): JSX.Element { const [activeTab, setActiveTab] = useState('pending'); const [searchQuery, setSearchQuery] = useState(''); const [sortValue, setSortValue] = useState('latest'); @@ -42,7 +39,8 @@ export default function ReviewRequest({ .filter((item) => { if (activeTab === 'pending') return item.status.toLowerCase() === 'needs_review'; if (activeTab === 'accepted') return item.status.toLowerCase() === 'completed'; - if (activeTab === 'rejected') return ['in_progress', 'pending'].includes(item.status.toLowerCase()); + if (activeTab === 'rejected') + return item.status.toLowerCase() === 'in_progress' || item.status.toLowerCase() === 'pending'; if (activeTab === 'all') return true; return false; }) @@ -51,10 +49,6 @@ export default function ReviewRequest({ switch (sortValue) { case 'oldest': return new Date(a.createdTime).getTime() - new Date(b.createdTime).getTime(); - case 'comments': - return b.commentsCount - a.commentsCount; - case 'updates': - return b.updatesCount - a.updatesCount; default: return new Date(b.createdTime).getTime() - new Date(a.createdTime).getTime(); } @@ -132,16 +126,13 @@ export default function ReviewRequest({
{filteredItems.map((item, index) => ( - ))} diff --git a/frontend/src/components/ReviewQuest/ReviewRequestItem.stories.tsx b/frontend/src/components/ReviewQuest/ReviewRequestItem.stories.tsx deleted file mode 100644 index a58e92d..0000000 --- a/frontend/src/components/ReviewQuest/ReviewRequestItem.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import '@/index.css'; -import type { Meta, StoryObj } from '@storybook/react'; -import ReviewRequestItem from './ReviewRequestItem'; - -const meta: Meta = { - title: 'Components/ReviewRequestItem', - component: ReviewRequestItem, - argTypes: { - title: { control: 'text', description: 'Title of the review request' }, - createdTime: { control: 'text', description: 'Time when the review was created' }, - creatorName: { control: 'text', description: 'Name of the creator' }, - project: { control: 'text', description: 'Project name' }, - status: { control: 'text', description: 'Status of the review request' }, - commentsCount: { control: 'number', description: 'Number of comments' }, - updatesCount: { control: 'number', description: 'Number of updates' }, - lastUpdated: { control: 'text', description: 'Time when the review was last updated' }, - type: { - control: 'object', - description: 'Label for the request type with text and color', - }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - title: 'Feat: 워크스페이스 멤버 추가 구현 - S11P21S002-37', - createdTime: '!24 · created 2 hours ago', - creatorName: '김태수', - project: 'be/develop', - status: 'Merged', - commentsCount: 4, - updatesCount: 1, - lastUpdated: 'updated 1 hour ago', - type: { text: 'Classification', color: '#a2eeef' }, - }, -}; diff --git a/frontend/src/components/ReviewQuest/ReviewRequestItem.tsx b/frontend/src/components/TaskList/TaskItem.tsx similarity index 65% rename from frontend/src/components/ReviewQuest/ReviewRequestItem.tsx rename to frontend/src/components/TaskList/TaskItem.tsx index 3bdaf9a..7f3978d 100644 --- a/frontend/src/components/ReviewQuest/ReviewRequestItem.tsx +++ b/frontend/src/components/TaskList/TaskItem.tsx @@ -1,6 +1,6 @@ import { Briefcase, MessageCircle, RefreshCw, Tag, Box, Layers, Pen } from 'lucide-react'; -interface ReviewRequestItemProps { +interface TaskItemProps { title: string; createdTime: string; creatorName: string; @@ -9,7 +9,9 @@ interface ReviewRequestItemProps { commentsCount: number; updatesCount: number; lastUpdated: string; - type: { text: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; color: string }; + type: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; + bgColor?: string; + memberCount: number; } const typeIcons: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', JSX.Element> = { @@ -19,14 +21,7 @@ const typeIcons: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', Polyline: , }; -const typeStyles: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', string> = { - Classification: '#a2eeef', - Detection: '#d4c5f9', - Polygon: '#f9c5d4', - Polyline: '#c5f9d4', -}; - -export default function ReviewRequestItem({ +export default function TaskItem({ title, createdTime, creatorName, @@ -36,12 +31,13 @@ export default function ReviewRequestItem({ updatesCount, lastUpdated, type, -}: ReviewRequestItemProps) { - const icon = typeIcons[type.text]; - const bgColor = typeStyles[type.text]; + bgColor = '#f3f4f6', + memberCount, +}: TaskItemProps) { + const icon = typeIcons[type]; return ( -
+

{title}

@@ -51,15 +47,13 @@ export default function ReviewRequestItem({

{project}

- {type && ( -
- {icon} - {type.text} -
- )} +
+ {icon} + {type} +
@@ -73,7 +67,8 @@ export default function ReviewRequestItem({ {updatesCount}
-

{lastUpdated}

+

Updated at {lastUpdated}

+
{`멤버 ${memberCount}명`}
); diff --git a/frontend/src/components/TaskList/TaskSearchInput.tsx b/frontend/src/components/TaskList/TaskSearchInput.tsx new file mode 100644 index 0000000..bb486ae --- /dev/null +++ b/frontend/src/components/TaskList/TaskSearchInput.tsx @@ -0,0 +1,48 @@ +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; +import SearchInput from '@/components/ui/search-input'; +import { cn } from '@/lib/utils'; + +const sortOptions = [ + { value: 'latest', label: '최신 순' }, + { value: 'oldest', label: '오래된 순' }, + { value: 'comments', label: '댓글 많은 순' }, + { value: 'updates', label: '업데이트 많은 순' }, +]; + +interface TaskSearchInputProps { + onSearchChange: (value: string) => void; + onSortChange: (value: string) => void; + sortValue: string; +} + +export default function TaskSearchInput({ onSearchChange, onSortChange, sortValue }: TaskSearchInputProps) { + return ( +
+
+ onSearchChange(e.target.value)} + /> + +
+
+ ); +} diff --git a/frontend/src/components/ReviewQuest/index.stories.tsx b/frontend/src/components/TaskList/index.stories.tsx similarity index 84% rename from frontend/src/components/ReviewQuest/index.stories.tsx rename to frontend/src/components/TaskList/index.stories.tsx index 21fb3df..c5bd9d5 100644 --- a/frontend/src/components/ReviewQuest/index.stories.tsx +++ b/frontend/src/components/TaskList/index.stories.tsx @@ -1,15 +1,14 @@ -import '@/index.css'; import type { Meta, StoryObj } from '@storybook/react'; -import ReviewRequest from '.'; +import TaskList from '.'; -const meta: Meta = { - title: 'Components/ReviewRequest', - component: ReviewRequest, +const meta: Meta = { + title: 'Components/TaskList', + component: TaskList, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { @@ -28,6 +27,7 @@ export const Default: Story = { commentsCount: 4, updatesCount: 1, lastUpdated: '1 hour ago', + memberCount: 3, }, { title: '갤럭시 흠집 객체 탐지', @@ -39,6 +39,7 @@ export const Default: Story = { commentsCount: 2, updatesCount: 3, lastUpdated: '30 minutes ago', + memberCount: 5, }, { title: '갤럭시 흠집 경계 폴리곤', @@ -50,6 +51,7 @@ export const Default: Story = { commentsCount: 3, updatesCount: 2, lastUpdated: '2 hours ago', + memberCount: 4, }, { title: '갤럭시 흠집 폴리라인', @@ -61,9 +63,10 @@ export const Default: Story = { commentsCount: 5, updatesCount: 0, lastUpdated: '20 minutes ago', + memberCount: 6, }, { - title: '갤럭시 흠집 디텍션 허가 요청', + title: '갤럭시 흠집 디텍션', createdTime: '6 hours ago', creatorName: 'Kim Tae Su', project: 'Project E', @@ -72,6 +75,7 @@ export const Default: Story = { commentsCount: 1, updatesCount: 4, lastUpdated: '3 hours ago', + memberCount: 2, }, ], }, diff --git a/frontend/src/components/TaskList/index.tsx b/frontend/src/components/TaskList/index.tsx new file mode 100644 index 0000000..822d64c --- /dev/null +++ b/frontend/src/components/TaskList/index.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import TaskItem from './TaskItem'; +import TaskSearchInput from './TaskSearchInput'; + +interface TaskListProps { + acceptedCount: number; + rejectedCount: number; + pendingCount: number; + totalCount: number; + items: { + title: string; + createdTime: string; + creatorName: string; + project: string; + type: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; + status: string; + commentsCount: number; + updatesCount: number; + lastUpdated: string; + memberCount: number; + }[]; +} + +const typeColors: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', string> = { + Classification: '#a2eeef', + Detection: '#d4c5f9', + Polygon: '#f9c5d4', + Polyline: '#c5f9d4', +}; + +export default function TaskList({ acceptedCount, pendingCount, totalCount, items }: TaskListProps): JSX.Element { + const [activeTab, setActiveTab] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [sortValue, setSortValue] = useState('latest'); + + const filteredItems = items + .filter((item) => { + if (activeTab === 'in_progress') return item.status.toLowerCase() === 'in_progress'; + if (activeTab === 'completed') return item.status.toLowerCase() === 'completed'; + if (activeTab === 'all') return true; + return false; + }) + .filter((item) => item.title.includes(searchQuery)) + .sort((a, b) => { + switch (sortValue) { + case 'oldest': + return new Date(a.createdTime).getTime() - new Date(b.createdTime).getTime(); + case 'comments': + return b.commentsCount - a.commentsCount; + case 'updates': + return b.updatesCount - a.updatesCount; + default: + return new Date(b.createdTime).getTime() - new Date(a.createdTime).getTime(); + } + }); + + return ( +
+
+
+ + + + + +
+
+ +
+ +
+ +
+ {filteredItems.map((item, index) => ( + + ))} +
+
+ ); +}