diff --git a/frontend/src/api/projectApi.ts b/frontend/src/api/projectApi.ts index bee98a4..b151b9c 100644 --- a/frontend/src/api/projectApi.ts +++ b/frontend/src/api/projectApi.ts @@ -1,5 +1,5 @@ import api from '@/api/axiosConfig'; -import { ProjectListResponse, ProjectResponse } from '@/types'; +import { ProjectListResponse, ProjectResponse, ProjectMemberRequest, ProjectMemberResponse } from '@/types'; export async function getProjectList( workspaceId: number, @@ -46,32 +46,6 @@ export async function deleteProject(projectId: number, memberId: number) { .then(({ data }) => data); } -export async function addProjectMember( - projectId: number, - memberId: number, - newMemberId: number, - privilegeType: string -) { - return api - .post( - `/projects/${projectId}/members`, - { memberId: newMemberId, privilegeType }, - { - params: { memberId }, - } - ) - .then(({ data }) => data); -} - -export async function removeProjectMember(projectId: number, memberId: number, targetMemberId: number) { - return api - .delete(`/projects/${projectId}/members`, { - params: { memberId }, - data: { memberId: targetMemberId }, - }) - .then(({ data }) => data); -} - export async function createProject( workspaceId: number, memberId: number, @@ -83,3 +57,43 @@ export async function createProject( }) .then(({ data }) => data); } + +// 프로젝트 멤버 조회 +export async function getProjectMembers(projectId: number, memberId: number) { + return api + .get(`/projects/${projectId}/members`, { + params: { memberId }, + }) + .then(({ data }) => data); +} + +// 프로젝트 멤버 추가 +export async function addProjectMember(projectId: number, memberId: number, newMember: ProjectMemberRequest) { + return api + .post(`/projects/${projectId}/members`, newMember, { + params: { memberId }, + }) + .then(({ data }) => data); +} + +// 프로젝트 멤버 권한 수정 +export async function updateProjectMemberPrivilege( + projectId: number, + memberId: number, + privilegeData: ProjectMemberRequest +) { + return api + .put(`/projects/${projectId}/members`, privilegeData, { + params: { memberId }, + }) + .then(({ data }) => data); +} + +// 프로젝트 멤버 삭제 +export async function removeProjectMember(projectId: number, memberId: number, targetMemberId: number) { + return api + .delete(`/projects/${projectId}/members`, { + params: { memberId, targetMemberId }, + }) + .then(({ data }) => data); +} diff --git a/frontend/src/api/reviewApi.ts b/frontend/src/api/reviewApi.ts new file mode 100644 index 0000000..6f10fc1 --- /dev/null +++ b/frontend/src/api/reviewApi.ts @@ -0,0 +1,70 @@ +import api from '@/api/axiosConfig'; +import { ReviewDetailResponse, ReviewRequest, ReviewResponse } from '@/types'; + +// 리뷰 단건 조회 +export async function getReviewDetail(projectId: number, reviewId: number, memberId: number) { + return api + .get(`/projects/${projectId}/reviews/${reviewId}`, { + params: { memberId }, + }) + .then(({ data }) => data); +} + +// 리뷰 생성 +export async function createReview(projectId: number, memberId: number, reviewData: ReviewRequest) { + return api + .post(`/projects/${projectId}/reviews`, reviewData, { + params: { memberId }, + }) + .then(({ data }) => data); +} + +// 리뷰 수정 +export async function updateReview(projectId: number, reviewId: number, memberId: number, reviewData: ReviewRequest) { + return api + .put(`/projects/${projectId}/reviews/${reviewId}`, reviewData, { + params: { memberId }, + }) + .then(({ data }) => data); +} + +// 리뷰 삭제 +export async function deleteReview(projectId: number, reviewId: number, memberId: number) { + return api + .delete(`/projects/${projectId}/reviews/${reviewId}`, { + params: { memberId }, + }) + .then(({ data }) => data); +} + +// 리뷰 상태 변경 +export async function updateReviewStatus(projectId: number, reviewId: number, memberId: number, reviewStatus: string) { + return api + .put( + `/projects/${projectId}/reviews/${reviewId}/status`, + { reviewStatus }, + { + params: { memberId }, + } + ) + .then(({ data }) => data); +} + +export async function getReviewByStatus( + projectId: number, + memberId: number, + reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED', + lastReviewId?: number, + limitPage: number = 10 +) { + return api + .get(`/projects/${projectId}/reviews`, { + params: { + memberId, + limitPage, + ...(reviewStatus ? { reviewStatus } : {}), + ...(lastReviewId ? { lastReviewId } : {}), + }, + }) + .then(({ data }) => data); +} diff --git a/frontend/src/components/AdminLayout/index.stories.tsx b/frontend/src/components/AdminLayout/index.stories.tsx deleted file mode 100644 index 286576f..0000000 --- a/frontend/src/components/AdminLayout/index.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import '@/index.css'; -import { Meta, StoryObj } from '@storybook/react'; -import AdminLayout from './index'; -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 index 012f841..d6c3f59 100644 --- a/frontend/src/components/AdminLayout/index.tsx +++ b/frontend/src/components/AdminLayout/index.tsx @@ -1,72 +1,24 @@ -import { Outlet } from 'react-router-dom'; -import { useParams } from 'react-router-dom'; +import { Outlet, useParams } 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) { - const { workspaceId } = useParams<{ workspaceId: string }>(); - - const numericWorkspaceId = workspaceId ? parseInt(workspaceId, 10) : 0; - - const effectiveWorkspace: Workspace = workspace || { - id: numericWorkspaceId, - name: workspaceId ? `workspace-${workspaceId}` : 'default-workspace', - projects: [ - { - id: 1, - name: 'project1', - type: 'Detection', - children: [], - }, - { - id: 2, - name: 'project2', - type: 'Detection', - children: [], - }, - { - id: 3, - name: 'project3', - type: 'Detection', - children: [], - }, - { - id: 4, - name: 'project4', - type: 'Detection', - children: [], - }, - { - id: 5, - name: 'project5', - type: 'Detection', - children: [], - }, - ], - }; +export default function AdminLayout() { + const { projectId } = useParams<{ projectId?: string }>(); return ( <>
- +
- + {projectId && }
diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx b/frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx index b8abdff..1bf1ee8 100644 --- a/frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx +++ b/frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx @@ -1,25 +1,29 @@ +import { useParams } from 'react-router-dom'; 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 { Form, FormControl, FormField, FormItem, FormMessage } from '../ui/form'; import { Input } from '../ui/input'; -import { Button } from '../ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import { ProjectMemberResponse } from '@/types'; +import { useUpdateProjectMemberPrivilege } from '@/hooks/useProjectHooks'; -type Role = 'admin' | 'editor' | 'viewer'; +type Role = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER'; -const roles: Role[] = ['admin', 'editor', 'viewer']; +const roles: Role[] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER']; const roleToStr: { [key in Role]: string } = { - admin: '관리자', - editor: '에디터', - viewer: '뷰어', + ADMIN: '관리자', + MANAGER: '매니저', + EDITOR: '에디터', + VIEWER: '뷰어', }; const formSchema = z.object({ members: z.array( z.object({ - email: z.string().email({ message: '올바른 이메일 형식을 입력해주세요.' }), + memberId: z.number(), + nickname: z.string().nonempty('닉네임을 입력하세요.'), role: z.enum(roles as [Role, ...Role[]], { errorMap: () => ({ message: '역할을 선택해주세요.' }) }), }) ), @@ -27,113 +31,95 @@ const formSchema = z.object({ export type MemberManageFormValues = z.infer; -interface Member { - email: string; - role: Role; -} - interface AdminMemberManageFormProps { - members: Member[]; - onSubmit: (data: MemberManageFormValues) => void; + members: ProjectMemberResponse[]; } -export default function AdminMemberManageForm({ members, onSubmit }: AdminMemberManageFormProps) { +export default function AdminMemberManageForm({ members }: AdminMemberManageFormProps) { + const { projectId } = useParams<{ projectId: string }>(); + const { mutate: updatePrivilege } = useUpdateProjectMemberPrivilege(); + const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: { members }, + defaultValues: { + members: members.map((m) => ({ + memberId: m.memberId, + nickname: m.nickname, + role: m.privilegeType as Role, + })), + }, }); - 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) - ); + const handleRoleChange = (memberId: number, role: Role) => { + updatePrivilege({ + projectId: Number(projectId), + memberId, + privilegeData: { + memberId, + privilegeType: role, + }, + }); + }; return (
- -
- {sortedGroupedMembers.map(([role, groupMembers]) => { - if (!groupMembers || groupMembers.length === 0) return null; +
+ {members.map((member, index) => ( +
+ ( + + + + + + + )} + /> - 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 deleted file mode 100644 index 0c7d414..0000000 --- a/frontend/src/components/AdminMemberManage/index.stories.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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 index e870a8b..e428b00 100644 --- a/frontend/src/components/AdminMemberManage/index.tsx +++ b/frontend/src/components/AdminMemberManage/index.tsx @@ -1,70 +1,45 @@ -import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; -import AdminMemberManageForm, { MemberManageFormValues } from './AdminMemberManageForm'; -import { Button } from '@/components/ui/button'; +import { useState } from 'react'; +import AdminMemberManageForm from './AdminMemberManageForm'; +import { useParams } from 'react-router-dom'; +import useProjectMembersQuery from '@/queries/useProjectMembersQuery'; +import useAuthStore from '@/stores/useAuthStore'; +import { useAddProjectMember } from '@/hooks/useProjectHooks'; +import MemberAddModal from '../MemberAddModal'; +import { MemberAddFormValues } from '../MemberAddModal/MemberAddForm'; -type Role = 'admin' | 'editor' | 'viewer'; +export default function AdminMemberManage() { + const { projectId } = useParams<{ workspaceId: string; projectId: string }>(); + const profile = useAuthStore((state) => state.profile); + const memberId = profile?.id || 0; -interface Member { - email: string; - role: Role; -} + const { data: members = [] } = useProjectMembersQuery(Number(projectId), memberId); + const addProjectMember = useAddProjectMember(); -interface Project { - id: string; - name: string; -} + const [, setInviteModalOpen] = useState(false); + + const handleMemberInvite = (data: MemberAddFormValues) => { + addProjectMember.mutate({ + projectId: Number(projectId), + memberId: memberId, + newMember: { + // Todo : 멤버 id로 수정하는 로직 수정해야한다. + // memberId: data.email, + memberId: 0, + privilegeType: data.role, + }, + }); + console.log('Invited:', data); + setInviteModalOpen(false); + }; -export default function AdminMemberManage({ - title = '멤버 관리', - projects = [ - { id: 'project-1', name: '프로젝트 A' }, - { id: 'project-2', name: '프로젝트 B' }, - ], - onProjectChange = (projectId: string) => console.log('Selected Project:', projectId), - onSubmit = (data: MemberManageFormValues) => console.log('Submitted:', data), - members = [ - { email: 'admin1@example.com', role: 'admin' }, - { email: 'viewer2@example.com', role: 'viewer' }, - ], - onMemberInvite = () => console.log('Invite member'), -}: { - 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 index 07d3bf8..47b1545 100644 --- a/frontend/src/components/AdminMenuSidebar/index.tsx +++ b/frontend/src/components/AdminMenuSidebar/index.tsx @@ -3,11 +3,11 @@ import { cn } from '@/lib/utils'; export default function AdminMenuSidebar() { const navigate = useNavigate(); - const { id } = useParams<{ id: string }>(); + const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>(); const menuItems = [ - { label: '리뷰', path: `/admin/${id}/review` }, - { label: '멤버 관리', path: `/admin/${id}/members` }, + { label: '리뷰', path: `/admin/${workspaceId}${projectId ? `/project/${projectId}` : ''}/reviews` }, + { label: '멤버 관리', path: `/admin/${workspaceId}${projectId ? `/project/${projectId}` : ''}/members` }, ]; return ( diff --git a/frontend/src/components/AdminProjectSidebar/index.tsx b/frontend/src/components/AdminProjectSidebar/index.tsx index b48d3b7..dc46d87 100644 --- a/frontend/src/components/AdminProjectSidebar/index.tsx +++ b/frontend/src/components/AdminProjectSidebar/index.tsx @@ -1,16 +1,31 @@ import { ResizablePanel, ResizableHandle } from '../ui/resizable'; -import { Project } from '@/types'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { SquarePen } from 'lucide-react'; -import { Button } from '../ui/button'; +import useProjectListQuery from '@/queries/useProjectListQuery'; +import { useCreateProject } from '@/hooks/useProjectHooks'; +import { ProjectRequest } from '@/types'; +import useAuthStore from '@/stores/useAuthStore'; +import ProjectCreateModal from '../ProjectCreateModal'; -interface AdminProjectSidebarProps { - workspaceName: string; - projects: Project[]; -} - -export default function AdminProjectSidebar({ workspaceName, projects }: AdminProjectSidebarProps): JSX.Element { +export default function AdminProjectSidebar(): JSX.Element { const navigate = useNavigate(); + const { workspaceId } = useParams<{ workspaceId: string }>(); + const profile = useAuthStore((state) => state.profile); + const memberId = profile?.id || 0; + + const { data: projectsResponse } = useProjectListQuery(Number(workspaceId), memberId); + + const projects = projectsResponse?.workspaceResponses ?? []; + + const createProject = useCreateProject(); + + const handleCreateProject = (data: ProjectRequest) => { + createProject.mutate({ + workspaceId: Number(workspaceId), + memberId, + data, + }); + }; return ( <> @@ -22,28 +37,24 @@ export default function AdminProjectSidebar({ workspaceName, projects }: AdminPr >

- {workspaceName} + {workspaceId}

- +
{projects.map((project) => ( ))}
diff --git a/frontend/src/components/Header/index.tsx b/frontend/src/components/Header/index.tsx index 0375839..67053c1 100644 --- a/frontend/src/components/Header/index.tsx +++ b/frontend/src/components/Header/index.tsx @@ -7,7 +7,7 @@ export interface HeaderProps extends React.HTMLAttributes {} export default function Header({ className, ...props }: HeaderProps) { const location = useLocation(); - const { workspaceId } = useParams<{ workspaceId: string }>(); + const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>(); const isWorkspaceIdNaN = isNaN(Number(workspaceId)); const isHomePage = location.pathname === '/'; @@ -46,7 +46,7 @@ export default function Header({ className, ...props }: HeaderProps) { labeling admin diff --git a/frontend/src/components/MemberAddModal/MemberAddForm.tsx b/frontend/src/components/MemberAddModal/MemberAddForm.tsx index 441ab58..b23745b 100644 --- a/frontend/src/components/MemberAddModal/MemberAddForm.tsx +++ b/frontend/src/components/MemberAddModal/MemberAddForm.tsx @@ -6,14 +6,15 @@ import { Input } from '../ui/input'; import { Button } from '../ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; -type Role = 'admin' | 'editor' | 'viewer'; +type PrivilegeType = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER'; -const roles: Role[] = ['admin', 'editor', 'viewer']; +const privilegeTypes: readonly ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER'] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER']; -const roleToStr: { [key in Role]: string } = { - admin: '관리자', - editor: '에디터', - viewer: '뷰어', +const privilegeTypeToStr: { [key in PrivilegeType]: string } = { + ADMIN: '관리자', + MANAGER: '매니저', + EDITOR: '에디터', + VIEWER: '뷰어', }; const formSchema = z.object({ @@ -26,7 +27,7 @@ const formSchema = z.object({ .min(1, { message: '초대할 멤버의 이메일 주소를 입력해주세요.', }), - role: z.enum(['admin', 'editor', 'viewer']), + role: z.enum(privilegeTypes), }); export type MemberAddFormValues = z.infer; @@ -80,12 +81,12 @@ export default function MemberAddForm({ onSubmit }: { onSubmit: (data: MemberAdd - {roles.map((role) => ( + {privilegeTypes.map((role) => ( - {roleToStr[role]} + {privilegeTypeToStr[role]} ))} diff --git a/frontend/src/components/MemberAddModal/index.stories.tsx b/frontend/src/components/MemberAddModal/index.stories.tsx deleted file mode 100644 index bf8c9d4..0000000 --- a/frontend/src/components/MemberAddModal/index.stories.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import '@/index.css'; -import MemberAddModal from '.'; - -export default { - title: 'Modal/MemberAddModal', - component: MemberAddModal, -}; - -export const Default = () => ( - { - console.log('close'); - }} - onSubmit={(data) => { - console.log(data); - }} - /> -); diff --git a/frontend/src/components/MemberAddModal/index.tsx b/frontend/src/components/MemberAddModal/index.tsx index 71264c0..9d568cb 100644 --- a/frontend/src/components/MemberAddModal/index.tsx +++ b/frontend/src/components/MemberAddModal/index.tsx @@ -1,27 +1,44 @@ +import React from 'react'; import MemberAddForm, { MemberAddFormValues } from './MemberAddForm'; -import XIcon from '@/assets/icons/x.svg?react'; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom'; +import { Button } from '@/components/ui/button'; +import { Plus } from 'lucide-react'; -export default function MemberAddModal({ - title = '새 멤버 초대', - onClose, - onSubmit, -}: { - title?: string; - onClose: () => void; +interface MemberAddModalProps { onSubmit: (data: MemberAddFormValues) => void; -}) { + buttonClass?: string; +} + +export default function MemberAddModal({ onSubmit, buttonClass = '' }: MemberAddModalProps) { + const [isOpen, setIsOpen] = React.useState(false); + + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + return ( -
-
-

{title}

- -
- -
+ + 멤버 초대하기 + + + + + { + onSubmit(data); + handleClose(); + }} + /> + + ); } diff --git a/frontend/src/components/ReviewDetail/index.tsx b/frontend/src/components/ReviewDetail/index.tsx new file mode 100644 index 0000000..a4d005a --- /dev/null +++ b/frontend/src/components/ReviewDetail/index.tsx @@ -0,0 +1,86 @@ +import useReviewDetailQuery from '@/queries/useReviewDetailQuery'; +import { useUpdateReview, useDeleteReview } from '@/hooks/useReviewHooks'; +import { useParams } from 'react-router-dom'; + +export default function ReviewDetail() { + const { projectId, reviewId } = useParams<{ projectId: string; reviewId: string }>(); + const memberId = 1; + + const { data: reviewDetail } = useReviewDetailQuery(Number(projectId), Number(reviewId), memberId); + const updateReview = useUpdateReview(); + const deleteReview = useDeleteReview(); + + const handleUpdate = () => { + updateReview.mutate({ + projectId: Number(projectId), + reviewId: Number(reviewId), + memberId, + reviewData: { + title: reviewDetail.title, + content: reviewDetail.content, + imageIds: reviewDetail.images.map((image) => image.id), + }, + }); + }; + + const handleDelete = () => { + deleteReview.mutate({ + projectId: Number(projectId), + reviewId: Number(reviewId), + memberId, + }); + }; + + if (!reviewDetail) return

Loading...

; + + const { title, content, reviewStatus, images } = reviewDetail; + + return ( +
+
+

{title}

+
{reviewStatus}
+
+ +
+

by 김용수

+

|

+

8 hours ago

+
+ +
+

내용

+

{content}

+
+ +
+

이미지 목록

+
    + {images.map((image) => ( +
  • + {image.imageTitle} (status: {image.status}) +
  • + ))} +
+
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/ReviewList/ReviewItem.tsx b/frontend/src/components/ReviewList/ReviewItem.tsx index 4784329..cfcbfc0 100644 --- a/frontend/src/components/ReviewList/ReviewItem.tsx +++ b/frontend/src/components/ReviewList/ReviewItem.tsx @@ -1,31 +1,23 @@ -import { Briefcase, Tag, Box, Layers, Pen } from 'lucide-react'; +import { Briefcase, Tag, Box, Layers } from 'lucide-react'; +import { ProjectResponse } from '@/types'; interface ReviewItemProps { title: string; createdTime: string; creatorName: string; - project: string; + project: ProjectResponse; status: string; - type: { text: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; color: string }; + type: { text: 'classification' | 'detection' | 'segmentation'; 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', +const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Element> = { + classification: , + detection: , + segmentation: , }; export default function ReviewItem({ title, createdTime, creatorName, project, status, type }: ReviewItemProps) { - const icon = typeIcons[type.text]; - const bgColor = typeStyles[type.text]; + const icon = typeIcons[project.projectType]; return (
@@ -34,12 +26,12 @@ export default function ReviewItem({ title, createdTime, creatorName, project, s

by {creatorName}

-

{project}

+

{project.title}

{type && (
{icon} {type.text} diff --git a/frontend/src/components/ReviewList/ReviewSearchInput.tsx b/frontend/src/components/ReviewList/ReviewSearchInput.tsx index 45b667f..dd3a197 100644 --- a/frontend/src/components/ReviewList/ReviewSearchInput.tsx +++ b/frontend/src/components/ReviewList/ReviewSearchInput.tsx @@ -5,8 +5,6 @@ import { cn } from '@/lib/utils'; const sortOptions = [ { value: 'latest', label: '최신 순' }, { value: 'oldest', label: '오래된 순' }, - { value: 'comments', label: '댓글 많은 순' }, - { value: 'updates', label: '업데이트 많은 순' }, ]; interface ReviewSearchInputProps { diff --git a/frontend/src/components/ReviewList/index.tsx b/frontend/src/components/ReviewList/index.tsx index 9221fbd..b55a38b 100644 --- a/frontend/src/components/ReviewList/index.tsx +++ b/frontend/src/components/ReviewList/index.tsx @@ -1,152 +1,66 @@ import { useState } from 'react'; import ReviewItem from './ReviewItem'; import ReviewSearchInput from './ReviewSearchInput'; +import useReviewByStatusQuery from '@/queries/useReviewByStatusQuery'; +import useProjectQuery from '@/queries/useProjectQuery'; +import useAuthStore from '@/stores/useAuthStore'; +import { useParams } from 'react-router-dom'; -interface ReviewListProps { - acceptedCount?: number; - rejectedCount?: number; - pendingCount?: number; - totalCount?: number; - items?: { - title: string; - createdTime: string; - creatorName: string; - project: string; - type: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; - status: string; - }[]; -} +export default function ReviewList(): JSX.Element { + const { projectId } = useParams<{ projectId: string }>(); + const profile = useAuthStore((state) => state.profile); + const memberId = profile?.id || 0; -const typeColors: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', string> = { - Classification: '#a2eeef', - Detection: '#d4c5f9', - Polygon: '#f9c5d4', - Polyline: '#c5f9d4', -}; - -const defaultItems: ReviewListProps['items'] = [ - { - title: '리뷰 항목 1', - createdTime: '2024-09-09T10:00:00Z', - creatorName: '사용자 1', - project: '프로젝트 A', - type: 'Classification', - status: 'needs_review', - }, - { - title: '리뷰 항목 2', - createdTime: '2024-09-08T14:30:00Z', - creatorName: '사용자 2', - project: '프로젝트 B', - type: 'Detection', - status: 'completed', - }, - { - title: '리뷰 항목 3', - createdTime: '2024-09-07T08:45:00Z', - creatorName: '사용자 3', - project: '프로젝트 C', - type: 'Polygon', - status: 'in_progress', - }, - { - title: '리뷰 항목 4', - createdTime: '2024-09-06T10:20:00Z', - creatorName: '사용자 4', - project: '프로젝트 D', - type: 'Polyline', - status: 'pending', - }, -]; - -export default function ReviewList({ - acceptedCount = 1, - rejectedCount = 1, - pendingCount = 1, - totalCount = 3, - items = defaultItems, -}: ReviewListProps): JSX.Element { - const [activeTab, setActiveTab] = useState('pending'); - const [searchQuery, setSearchQuery] = useState(''); + const [activeTab, setActiveTab] = useState<'REQUESTED' | 'APPROVED' | 'REJECTED' | 'all'>('REQUESTED'); + const [, setSearchQuery] = useState(''); const [sortValue, setSortValue] = useState('latest'); - const filteredItems = (items ?? []) - .filter((item) => { - if (activeTab === 'pending') return item.status.toLowerCase() === 'needs_review'; - if (activeTab === 'accepted') return item.status.toLowerCase() === 'completed'; - if (activeTab === 'rejected') - return item.status.toLowerCase() === 'in_progress' || item.status.toLowerCase() === 'pending'; - 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(); - default: - return new Date(b.createdTime).getTime() - new Date(a.createdTime).getTime(); - } - }); + const { data: project } = useProjectQuery(Number(projectId), memberId); + + const { data: reviews = [] } = useReviewByStatusQuery( + Number(projectId), + memberId, + activeTab !== 'all' ? activeTab : undefined + ); return (
@@ -160,15 +74,23 @@ export default function ReviewList({
- {filteredItems.map((item, index) => ( + {reviews.map((item) => ( ))}
diff --git a/frontend/src/hooks/useProjectHooks.ts b/frontend/src/hooks/useProjectHooks.ts index 84ed741..29e3c58 100644 --- a/frontend/src/hooks/useProjectHooks.ts +++ b/frontend/src/hooks/useProjectHooks.ts @@ -113,8 +113,15 @@ // }); // }; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createProject, updateProject, deleteProject, addProjectMember, removeProjectMember } from '@/api/projectApi'; -import { ProjectResponse, ProjectRequest, ProjectMemberRequest } from '@/types'; +import { + createProject, + updateProject, + deleteProject, + addProjectMember, + updateProjectMemberPrivilege, + removeProjectMember, +} from '@/api/projectApi'; +import { ProjectResponse, ProjectRequest, ProjectMemberRequest, ProjectMemberResponse } from '@/types'; export const useCreateProject = () => { const queryClient = useQueryClient(); @@ -151,23 +158,41 @@ export const useDeleteProject = () => { export const useAddProjectMember = () => { const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ projectId, memberId, data }) => - addProjectMember(projectId, memberId, data.memberId, data.privilegeType), - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] }); + return useMutation< + ProjectMemberResponse, + Error, + { projectId: number; memberId: number; newMember: ProjectMemberRequest } + >({ + mutationFn: ({ projectId, memberId, newMember }) => addProjectMember(projectId, memberId, newMember), + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] }); }, }); }; +// 프로젝트 멤버 권한 수정 훅 +export const useUpdateProjectMemberPrivilege = () => { + const queryClient = useQueryClient(); + return useMutation< + ProjectMemberResponse, + Error, + { projectId: number; memberId: number; privilegeData: ProjectMemberRequest } + >({ + mutationFn: ({ projectId, memberId, privilegeData }) => + updateProjectMemberPrivilege(projectId, memberId, privilegeData), + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] }); + }, + }); +}; + +// 프로젝트 멤버 삭제 훅 export const useRemoveProjectMember = () => { const queryClient = useQueryClient(); - return useMutation({ mutationFn: ({ projectId, memberId, targetMemberId }) => removeProjectMember(projectId, memberId, targetMemberId), - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] }); + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] }); }, }); }; diff --git a/frontend/src/hooks/useReviewHooks.ts b/frontend/src/hooks/useReviewHooks.ts new file mode 100644 index 0000000..75f1512 --- /dev/null +++ b/frontend/src/hooks/useReviewHooks.ts @@ -0,0 +1,57 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createReview, updateReview, deleteReview, updateReviewStatus } from '@/api/reviewApi'; +import { ReviewRequest, ReviewResponse } from '@/types'; + +// 리뷰 생성 훅 +export const useCreateReview = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ projectId, memberId, reviewData }) => createReview(projectId, memberId, reviewData), + onSuccess: (_, { projectId, memberId }) => { + queryClient.invalidateQueries({ queryKey: ['reviewList', projectId, memberId] }); + }, + }); +}; + +// 리뷰 수정 훅 +export const useUpdateReview = () => { + const queryClient = useQueryClient(); + return useMutation< + ReviewResponse, + Error, + { projectId: number; reviewId: number; memberId: number; reviewData: ReviewRequest } + >({ + mutationFn: ({ projectId, reviewId, memberId, reviewData }) => + updateReview(projectId, reviewId, memberId, reviewData), + onSuccess: (_, { projectId, reviewId }) => { + queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId] }); + }, + }); +}; + +// 리뷰 삭제 훅 +export const useDeleteReview = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ projectId, reviewId, memberId }) => deleteReview(projectId, reviewId, memberId), + onSuccess: (_, { projectId, reviewId }) => { + queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId] }); + }, + }); +}; + +// 리뷰 상태 변경 훅 +export const useUpdateReviewStatus = () => { + const queryClient = useQueryClient(); + return useMutation< + ReviewResponse, + Error, + { projectId: number; reviewId: number; memberId: number; reviewStatus: string } + >({ + mutationFn: ({ projectId, reviewId, memberId, reviewStatus }) => + updateReviewStatus(projectId, reviewId, memberId, reviewStatus), + onSuccess: (_, { projectId, reviewId }) => { + queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId] }); + }, + }); +}; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index fe91548..0d02b4b 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -155,6 +155,93 @@ export const handlers = [ return HttpResponse.json({}); }), + http.post('/api/projects/:projectId/label/auto', () => { + const response: AutoLabelingResponse = { + imageId: 1, + imageUrl: 'image-url.jpg', + data: `{ + "version": "0.1.0", + "task_type": "cls", + "shapes": [ + { + "label": "NG", + "color": "#FF0000", + "points": [[0, 0]], + "group_id": null, + "shape_type": "point", + "flags": {} + } + ], + "split": "none", + "imageHeight": 2000, + "imageWidth": 4000, + "imageDepth": 4 + }`, + }; + return HttpResponse.json(response); + }), + + // DELETE: 프로젝트 멤버 제거 핸들러 + http.delete('/api/projects/:projectId/members', ({ params }) => { + const { projectId } = params; + + return HttpResponse.json({ message: `프로젝트 ${projectId}에서 멤버 제거 성공` }); + }), + // PUT: 프로젝트 멤버 권한 수정 핸들러 + http.put('/api/projects/:projectId/members', () => { + return HttpResponse.json({}); + }), + // POST: 워크스페이스 멤버 추가 핸들러 + http.post('/api/workspaces/:workspaceId/members/:memberId', ({ params }) => { + const { workspaceId, memberId } = params; + + if (!workspaceId || !memberId) { + const errorResponse: ErrorResponse = { + status: 400, + code: 1002, + message: '잘못된 요청입니다. 요청을 확인해주세요.', + isSuccess: false, + }; + return HttpResponse.json(errorResponse, { status: 400 }); + } + + // 성공 응답 + const response: WorkspaceResponse = { + id: parseInt(workspaceId as string, 10), + memberId: parseInt(memberId as string, 10), + title: 'Workspace 1', + content: 'Workspace for testing', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + return HttpResponse.json(response, { status: 200 }); + }), + + // GET: 프로젝트 멤버 리스트 조회 핸들러 (가상) + // 실제 구현 시 API 경로와 메서드를 확인 후 업데이트 필요 + http.get('/api/projects/:projectId/members', () => { + const members: MemberResponse[] = [ + { id: 1, nickname: 'admin', profileImage: 'admin.jpg' }, + { id: 2, nickname: 'editor', profileImage: 'editor.jpg' }, + { id: 3, nickname: 'viewer', profileImage: 'viewer.jpg' }, + ]; + + return HttpResponse.json(members); + }), + + // GET: 워크스페이스 멤버 리스트 조회 핸들러 (가상) + // 실제 구현 시 API 경로와 메서드를 확인 후 업데이트 필요 + http.get('/api/workspaces/:workspaceId/members', () => { + const members: MemberResponse[] = [ + { id: 1, nickname: 'admin', profileImage: 'admin.jpg' }, + { id: 2, nickname: 'editor', profileImage: 'editor.jpg' }, + { id: 3, nickname: 'viewer', profileImage: 'viewer.jpg' }, + ]; + + return HttpResponse.json(members); + }), + // Folder and Image Handlers http.get('/api/projects/:projectId/folders/:folderId', ({ params }) => { const { folderId } = params; diff --git a/frontend/src/pages/AdminIndex.tsx b/frontend/src/pages/AdminIndex.tsx new file mode 100644 index 0000000..5257a85 --- /dev/null +++ b/frontend/src/pages/AdminIndex.tsx @@ -0,0 +1,15 @@ +import { Smile } from 'lucide-react'; + +export default function AdminIndex() { + return ( +
+
+ +
프로젝트를 선택하거나 생성하세요.
+
+
+ ); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 43db381..a59fe75 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'; import GoogleLogo from '@/assets/icons/web_neutral_rd_ctn@1x.png'; import useAuthStore from '@/stores/useAuthStore'; import { Button } from '@/components/ui/button'; -import { getProfile, reissueToken } from '@/api/authApi'; +import { getProfile } from '@/api/authApi'; const BASE_URL = import.meta.env.VITE_API_URL; export default function Home() { @@ -17,20 +17,10 @@ export default function Home() { hasFetchedProfile.current = true; }); } - - const handleReissueToken = async () => { - try { - const response = await reissueToken(); - console.log('토큰 재발급 성공:', response); - alert('토큰 재발급 성공! 새로운 액세스 토큰을 콘솔에서 확인하세요.'); - } catch (error) { - console.error('토큰 재발급 실패:', error); - alert('토큰 재발급에 실패했습니다. 다시 시도해 주세요.'); - } + const handleGoogleSignIn = () => { + window.location.href = `${BASE_URL}/api/login/oauth2/authorization/google`; }; - const isHidden = true; - return (
@@ -53,9 +43,19 @@ export default function Home() {
{!isLoggedIn ? ( - + // Sign in with Google + // + // 404 에러 방지 ) : ( <> - )}
diff --git a/frontend/src/queries/useProjectMembersQuery.ts b/frontend/src/queries/useProjectMembersQuery.ts new file mode 100644 index 0000000..c5e0241 --- /dev/null +++ b/frontend/src/queries/useProjectMembersQuery.ts @@ -0,0 +1,10 @@ +import { getProjectMembers } from '@/api/projectApi'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { ProjectMemberResponse } from '@/types'; + +export default function useProjectMembersQuery(projectId: number, memberId: number) { + return useSuspenseQuery({ + queryKey: ['projectMembers', projectId, memberId], + queryFn: () => getProjectMembers(projectId, memberId), + }); +} diff --git a/frontend/src/queries/useReviewByStatusQuery.ts b/frontend/src/queries/useReviewByStatusQuery.ts new file mode 100644 index 0000000..d677d8e --- /dev/null +++ b/frontend/src/queries/useReviewByStatusQuery.ts @@ -0,0 +1,13 @@ +import { getReviewByStatus } from '@/api/reviewApi'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +export default function useReviewByStatusQuery( + projectId: number, + memberId: number, + reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED' | undefined +) { + return useSuspenseQuery({ + queryKey: ['reviewByStatus', projectId, reviewStatus], + queryFn: () => getReviewByStatus(projectId, memberId, reviewStatus), + }); +} diff --git a/frontend/src/queries/useReviewDetailQuery.ts b/frontend/src/queries/useReviewDetailQuery.ts new file mode 100644 index 0000000..c330e4a --- /dev/null +++ b/frontend/src/queries/useReviewDetailQuery.ts @@ -0,0 +1,9 @@ +import { getReviewDetail } from '@/api/reviewApi'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +export default function useReviewDetailQuery(projectId: number, reviewId: number, memberId: number) { + return useSuspenseQuery({ + queryKey: ['reviewDetail', projectId, reviewId, memberId], + queryFn: () => getReviewDetail(projectId, reviewId, memberId), + }); +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 007d1c4..bb914ae 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -9,16 +9,13 @@ import ReviewList from '@/components/ReviewList'; import AdminMemberManage from '@/components/AdminMemberManage'; import OAuthCallback from '@/components/OAuthCallback'; import { createBrowserRouter } from 'react-router-dom'; -import { Navigate } from 'react-router-dom'; import { Suspense } from 'react'; import WorkspaceBrowseIndex from '@/pages/WorkspaceBrowseIndex'; - +import AdminIndex from '@/pages/AdminIndex'; export const webPath = { home: () => '/', browse: () => '/browse', workspace: () => '/workspace', - // workspace: (workspaceId: string, projectId?: string) => - // projectId ? `/workspace/${workspaceId}/project/${projectId}` : `/workspace/${workspaceId}`, admin: () => `/admin`, oauthCallback: () => '/redirect/oauth2', }; @@ -73,15 +70,19 @@ const router = createBrowserRouter([ ], }, { - path: `${webPath.admin()}/:workspaceId`, - element: , + path: `${webPath.admin()}/:workspaceId/project/:projectId?`, + element: ( +
}> + + + ), children: [ { index: true, - element: , + element: , }, { - path: 'review', + path: 'reviews', element: , }, { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5e658d9..296e58b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -149,23 +149,52 @@ export interface AutoLabelingResponse { data: string; } +// 리뷰 요청 DTO export interface ReviewRequest { title: string; content: string; imageIds: number[]; } +// 리뷰 응답 DTO export interface ReviewResponse { reviewId: number; title: string; content: string; status: 'REQUESTED' | 'APPROVED' | 'REJECTED'; + nickname: string; + email: string; + createAt: string; + updateAt: string; } +// 리뷰 상태 요청 DTO export interface ReviewStatusRequest { reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED'; } +// 리뷰 이미지 응답 DTO +export interface ReviewImageResponse { + id: number; // 이미지 ID + imageTitle: string; // 이미지 파일 제목 + status: 'PENDING' | 'IN_PROGRESS' | 'SAVE' | 'REVIEW_REQUEST' | 'COMPLETED'; +} + +// 리뷰 디테일 응답 DTO +export interface ReviewDetailResponse { + reviewId: number; + title: string; + content: string; + reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED'; + images: ReviewImageResponse[]; +} +// 프로젝트 멤버 응답 DTO +export interface ProjectMemberResponse { + memberId: number; + nickname: string; + profileImage: string; + privilegeType: 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER'; +} export interface FolderIdResponse { id: number; title: string; @@ -175,7 +204,7 @@ export interface ImageDetailResponse { id: number; imageTitle: string; imageUrl: string; - data: string | null; // PENDING 상태라면 null + data: string | null; status: 'PENDING' | 'IN_PROGRESS' | 'SAVE' | 'REVIEW_REQUEST' | 'COMPLETED'; } @@ -192,12 +221,16 @@ export interface LabelSaveRequest { data: string; } -export interface ReviewDetailResponse { - reviewId: number; - title: string; - content: string; - reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED'; - images: ImageResponse[]; +export interface ProjectMemberResponse { + memberId: number; + nickname: string; + profileImage: string; + privilegeType: 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER'; +} + +export interface ProjectMemberRequest { + memberId: number; + privilegeType: 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER'; } export interface ErrorResponse {