From 2cb13477fba290cbd7fdf462e6d301b734d78b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Wed, 18 Sep 2024 20:25:20 +0900 Subject: [PATCH 1/8] =?UTF-8?q?Feat:=20=EB=A6=AC=EB=B7=B0=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=9B=85,=20=EC=BF=BC=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/reviewApi.ts | 60 +++++++++++++ frontend/src/hooks/useReviewHooks.ts | 57 ++++++++++++ frontend/src/mocks/handlers.ts | 87 +++++++++++++++++++ .../src/queries/useReviewByStatusQuery.ts | 9 ++ frontend/src/queries/useReviewDetailQuery.ts | 9 ++ 5 files changed, 222 insertions(+) create mode 100644 frontend/src/api/reviewApi.ts create mode 100644 frontend/src/hooks/useReviewHooks.ts create mode 100644 frontend/src/queries/useReviewByStatusQuery.ts create mode 100644 frontend/src/queries/useReviewDetailQuery.ts diff --git a/frontend/src/api/reviewApi.ts b/frontend/src/api/reviewApi.ts new file mode 100644 index 0000000..031e763 --- /dev/null +++ b/frontend/src/api/reviewApi.ts @@ -0,0 +1,60 @@ +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: string) { + return api + .get(`/projects/${projectId}/reviews`, { + params: { memberId, reviewStatus }, + }) + .then(({ data }) => data); +} 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/queries/useReviewByStatusQuery.ts b/frontend/src/queries/useReviewByStatusQuery.ts new file mode 100644 index 0000000..ade68c6 --- /dev/null +++ b/frontend/src/queries/useReviewByStatusQuery.ts @@ -0,0 +1,9 @@ +import { getReviewByStatus } from '@/api/reviewApi'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +export default function useReviewByStatusQuery(projectId: number, memberId: number, reviewStatus: string) { + 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), + }); +} From b9aa2554860995b1920736452922b7d72da47de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Wed, 18 Sep 2024 20:33:18 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=ED=99=88=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Home.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 43db381..eedcb80 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -17,7 +17,9 @@ export default function Home() { hasFetchedProfile.current = true; }); } - + const handleGoogleSignIn = () => { + window.location.href = `${BASE_URL}/api/login/oauth2/authorization/google`; + }; const handleReissueToken = async () => { try { const response = await reissueToken(); @@ -53,9 +55,19 @@ export default function Home() { {!isLoggedIn ? ( - + // Sign in with Google + // + // 404 에러 방지 ) : ( <> - +
{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/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..d5a990d 100644 --- a/frontend/src/components/ReviewList/index.tsx +++ b/frontend/src/components/ReviewList/index.tsx @@ -1,152 +1,63 @@ import { useState } from 'react'; import ReviewItem from './ReviewItem'; import ReviewSearchInput from './ReviewSearchInput'; +import useReviewByStatusQuery from '@/queries/useReviewByStatusQuery'; +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: reviews = [] } = useReviewByStatusQuery( + Number(projectId), + memberId, + activeTab !== 'all' ? activeTab : undefined + ); return (
@@ -160,15 +71,15 @@ 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/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/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 index ade68c6..d677d8e 100644 --- a/frontend/src/queries/useReviewByStatusQuery.ts +++ b/frontend/src/queries/useReviewByStatusQuery.ts @@ -1,7 +1,11 @@ import { getReviewByStatus } from '@/api/reviewApi'; import { useSuspenseQuery } from '@tanstack/react-query'; -export default function useReviewByStatusQuery(projectId: number, memberId: number, reviewStatus: string) { +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/router/index.tsx b/frontend/src/router/index.tsx index 007d1c4..fc6bfdb 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,15 @@ const router = createBrowserRouter([ ], }, { - path: `${webPath.admin()}/:workspaceId`, + 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 { From 889cf872d030092a5721696548824ec949e4c86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Thu, 19 Sep 2024 01:55:21 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Feat:=20=EB=A9=A4=EB=B2=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=2070%=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminMemberManageForm.tsx | 194 ++++++++---------- .../components/AdminMemberManage/index.tsx | 93 +++------ .../MemberAddModal/MemberAddForm.tsx | 19 +- .../src/components/MemberAddModal/index.tsx | 57 +++-- frontend/src/router/index.tsx | 6 +- 5 files changed, 176 insertions(+), 193 deletions(-) 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.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/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.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/router/index.tsx b/frontend/src/router/index.tsx index fc6bfdb..bb914ae 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -71,7 +71,11 @@ const router = createBrowserRouter([ }, { path: `${webPath.admin()}/:workspaceId/project/:projectId?`, - element: , + element: ( + }> + + + ), children: [ { index: true, From 2da76c1bf303861b0d025ec443ee2f51740020cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Thu, 19 Sep 2024 08:18:22 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Test:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84=EB=A3=8C=20=ED=9B=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Home.tsx | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index eedcb80..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() { @@ -20,18 +20,6 @@ export default function Home() { const handleGoogleSignIn = () => { window.location.href = `${BASE_URL}/api/login/oauth2/authorization/google`; }; - const handleReissueToken = async () => { - try { - const response = await reissueToken(); - console.log('토큰 재발급 성공:', response); - alert('토큰 재발급 성공! 새로운 액세스 토큰을 콘솔에서 확인하세요.'); - } catch (error) { - console.error('토큰 재발급 실패:', error); - alert('토큰 재발급에 실패했습니다. 다시 시도해 주세요.'); - } - }; - - const isHidden = true; return (
@@ -85,15 +73,6 @@ export default function Home() { > 시작하기 - )}
From 3e397556bbec35243fc1488eff9b34762c3cb4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Thu, 19 Sep 2024 08:44:45 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AdminLayout/index.stories.tsx | 39 ------------------ .../AdminMemberManage/index.stories.tsx | 40 ------------------- .../MemberAddModal/index.stories.tsx | 19 --------- 3 files changed, 98 deletions(-) delete mode 100644 frontend/src/components/AdminLayout/index.stories.tsx delete mode 100644 frontend/src/components/AdminMemberManage/index.stories.tsx delete mode 100644 frontend/src/components/MemberAddModal/index.stories.tsx 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/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/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); - }} - /> -); From 9156608ceac689092a58687fb3e7893b4f4dbcba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Thu, 19 Sep 2024 09:16:42 +0900 Subject: [PATCH 7/8] =?UTF-8?q?Refactor:=20=EB=A6=AC=EB=B7=B0=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8,=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ReviewList/ReviewItem.tsx | 30 +++++++------------ frontend/src/components/ReviewList/index.tsx | 15 ++++++++-- 2 files changed, 24 insertions(+), 21 deletions(-) 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/index.tsx b/frontend/src/components/ReviewList/index.tsx index d5a990d..b55a38b 100644 --- a/frontend/src/components/ReviewList/index.tsx +++ b/frontend/src/components/ReviewList/index.tsx @@ -2,6 +2,7 @@ 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'; @@ -14,6 +15,8 @@ export default function ReviewList(): JSX.Element { const [, setSearchQuery] = useState(''); const [sortValue, setSortValue] = useState('latest'); + const { data: project } = useProjectQuery(Number(projectId), memberId); + const { data: reviews = [] } = useReviewByStatusQuery( Number(projectId), memberId, @@ -77,9 +80,17 @@ export default function ReviewList(): JSX.Element { title={item.title} createdTime={item.createAt} creatorName={item.nickname} - project={item.content} + project={project} status={item.status} - type={{ text: 'Classification', color: '#a2eeef' }} + type={{ + text: project.projectType, + color: + project.projectType === 'classification' + ? '#a2eeef' + : project.projectType === 'detection' + ? '#d4c5f9' + : '#f9c5d4', + }} /> ))}
From ea2c0c94c03cdcc5c7bb829391a204fa3d459676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Thu, 19 Sep 2024 09:17:08 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Feat:=20=EB=A6=AC=EB=B7=B0=20=EB=94=94?= =?UTF-8?q?=ED=85=8C=EC=9D=BC=20=EA=B0=80=EC=95=88=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ReviewDetail/index.tsx | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 frontend/src/components/ReviewDetail/index.tsx 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}) +
  • + ))} +
+
+ +
+ + +
+
+ ); +}