diff --git a/frontend/src/api/workspaceApi.ts b/frontend/src/api/workspaceApi.ts index 04c86e3..bc45383 100644 --- a/frontend/src/api/workspaceApi.ts +++ b/frontend/src/api/workspaceApi.ts @@ -1,5 +1,5 @@ import api from '@/api/axiosConfig'; -import { WorkspaceListResponse, WorkspaceRequest, WorkspaceResponse } from '@/types'; +import { WorkspaceListResponse, WorkspaceRequest, WorkspaceResponse, ReviewResponse } from '@/types'; export async function getWorkspaceList(memberId: number, lastWorkspaceId?: number, limit?: number) { return api @@ -49,10 +49,21 @@ export async function addWorkspaceMember(workspaceId: number, memberId: number, .then(({ data }) => data); } -export async function removeWorkspaceMember(workspaceId: number, memberId: number, targetMemberId: number) { +export async function getWorkspaceReviews( + workspaceId: number, + memberId: number, + reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED', + lastReviewId?: number, + limitPage: number = 10 +) { return api - .delete(`/workspaces/${workspaceId}/members/${targetMemberId}`, { - params: { memberId }, + .get(`/workspaces/${workspaceId}/reviews`, { + params: { + memberId, + limitPage, + ...(reviewStatus ? { reviewStatus } : {}), + ...(lastReviewId ? { lastReviewId } : {}), + }, }) .then(({ data }) => data); } diff --git a/frontend/src/components/AdminLayout/index.tsx b/frontend/src/components/AdminLayout/index.tsx index d6c3f59..5818b8f 100644 --- a/frontend/src/components/AdminLayout/index.tsx +++ b/frontend/src/components/AdminLayout/index.tsx @@ -1,25 +1,31 @@ -import { Outlet, useParams } from 'react-router-dom'; +import { Outlet, useMatch } from 'react-router-dom'; import Header from '../Header'; import { ResizablePanelGroup, ResizablePanel } from '../ui/resizable'; import AdminProjectSidebar from '../AdminProjectSidebar'; import AdminMenuSidebar from '../AdminMenuSidebar'; export default function AdminLayout() { - const { projectId } = useParams<{ projectId?: string }>(); + const isIndexPage = useMatch({ path: '/admin/:workspaceId', end: true }); return ( <>
- + {!isIndexPage && } +
- {projectId && } +
+ {isIndexPage && ( +
+ +
+ )}
); diff --git a/frontend/src/components/AdminMenuSidebar/index.tsx b/frontend/src/components/AdminMenuSidebar/index.tsx index 47b1545..d62611b 100644 --- a/frontend/src/components/AdminMenuSidebar/index.tsx +++ b/frontend/src/components/AdminMenuSidebar/index.tsx @@ -3,11 +3,17 @@ import { cn } from '@/lib/utils'; export default function AdminMenuSidebar() { const navigate = useNavigate(); - const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>(); + const { workspaceId } = useParams<{ workspaceId: string }>(); const menuItems = [ - { label: '리뷰', path: `/admin/${workspaceId}${projectId ? `/project/${projectId}` : ''}/reviews` }, - { label: '멤버 관리', path: `/admin/${workspaceId}${projectId ? `/project/${projectId}` : ''}/members` }, + { + label: '리뷰', + path: `/admin/${workspaceId}/reviews`, + }, + { + label: '멤버 관리', + path: `/admin/${workspaceId}/members`, + }, ]; return ( diff --git a/frontend/src/components/AdminProjectSidebar/index.tsx b/frontend/src/components/AdminProjectSidebar/index.tsx index d31838a..1afc83c 100644 --- a/frontend/src/components/AdminProjectSidebar/index.tsx +++ b/frontend/src/components/AdminProjectSidebar/index.tsx @@ -1,20 +1,24 @@ import { ResizablePanel, ResizableHandle } from '../ui/resizable'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useLocation, useParams } from 'react-router-dom'; import { SquarePen } from 'lucide-react'; import useProjectListQuery from '@/queries/projects/useProjectListQuery'; import useCreateProjectQuery from '@/queries/projects/useCreateProjectQuery'; +import useWorkspaceQuery from '@/queries/workspaces/useWorkspaceQuery'; import { ProjectRequest } from '@/types'; import useAuthStore from '@/stores/useAuthStore'; import ProjectCreateModal from '../ProjectCreateModal'; export default function AdminProjectSidebar(): JSX.Element { const navigate = useNavigate(); + const location = useLocation(); const { workspaceId } = useParams<{ workspaceId: string }>(); const profile = useAuthStore((state) => state.profile); const memberId = profile?.id || 0; - const { data: projectsResponse } = useProjectListQuery(Number(workspaceId), memberId); + const { data: workspaceData } = useWorkspaceQuery(Number(workspaceId), memberId); + const workspaceTitle = workspaceData?.title || `Workspace-${workspaceId}`; + const { data: projectsResponse } = useProjectListQuery(Number(workspaceId), memberId); const projects = projectsResponse?.workspaceResponses ?? []; const createProject = useCreateProjectQuery(); @@ -27,6 +31,21 @@ export default function AdminProjectSidebar(): JSX.Element { }); }; + const handleProjectClick = (projectId: number) => { + const searchParams = new URLSearchParams(location.search); + searchParams.set('projectId', String(projectId)); + navigate({ + search: `?${searchParams.toString()}`, + }); + }; + + const handleHeaderClick = () => { + navigate({ + pathname: `/admin/${workspaceId}`, + search: '', + }); + }; + return ( <>
-

- {workspaceId} +

+ {workspaceTitle}

diff --git a/frontend/src/components/ReviewList/ProjectReviewList.tsx b/frontend/src/components/ReviewList/ProjectReviewList.tsx new file mode 100644 index 0000000..e2ec225 --- /dev/null +++ b/frontend/src/components/ReviewList/ProjectReviewList.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import ReviewItem from './ReviewItem'; +import ReviewSearchInput from './ReviewSearchInput'; +import useReviewByStatusQuery from '@/queries/reviews/useReviewByStatusQuery'; +import useAuthStore from '@/stores/useAuthStore'; + +interface ProjectReviewListProps { + projectId: number; + workspaceId: number; +} + +export default function ProjectReviewList({ projectId }: ProjectReviewListProps): JSX.Element { + const profile = useAuthStore((state) => state.profile); + const memberId = profile?.id || 0; + + const [activeTab, setActiveTab] = useState<'REQUESTED' | 'APPROVED' | 'REJECTED' | 'all'>('REQUESTED'); + const [, setSearchQuery] = useState(''); + const [sortValue, setSortValue] = useState('latest'); + + const { data: projectReviews = [] } = useReviewByStatusQuery( + projectId, + memberId, + activeTab !== 'all' ? activeTab : undefined + ); + + return ( +
+
+
+ {['REQUESTED', 'APPROVED', 'REJECTED', 'all'].map((tab) => ( + + ))} +
+
+ +
+ +
+ +
+ {projectReviews.length === 0 ? ( +
프로젝트에 리뷰가 없습니다.
+ ) : ( + projectReviews.map((item) => ( + + )) + )} +
+
+ ); +} diff --git a/frontend/src/components/ReviewList/ReviewItem.tsx b/frontend/src/components/ReviewList/ReviewItem.tsx index cfcbfc0..6ef0d01 100644 --- a/frontend/src/components/ReviewList/ReviewItem.tsx +++ b/frontend/src/components/ReviewList/ReviewItem.tsx @@ -1,13 +1,13 @@ import { Briefcase, Tag, Box, Layers } from 'lucide-react'; -import { ProjectResponse } from '@/types'; - +import useProjectQuery from '@/queries/projects/useProjectQuery'; +import useAuthStore from '@/stores/useAuthStore'; interface ReviewItemProps { title: string; createdTime: string; creatorName: string; - project: ProjectResponse; - status: string; - type: { text: 'classification' | 'detection' | 'segmentation'; color: string }; + projectId: number; + status: 'REQUESTED' | 'APPROVED' | 'REJECTED'; + type?: { text: 'classification' | 'detection' | 'segmentation'; color: string }; } const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Element> = { @@ -16,9 +16,11 @@ const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Ele segmentation: , }; -export default function ReviewItem({ title, createdTime, creatorName, project, status, type }: ReviewItemProps) { - const icon = typeIcons[project.projectType]; - +export default function ReviewItem({ title, createdTime, creatorName, projectId, status, type }: ReviewItemProps) { + const profile = useAuthStore((state) => state.profile); + const memberId = profile?.id || 0; + const icon = type ? typeIcons[type.text] : null; + const { data: projectData } = useProjectQuery(projectId, memberId); return (
@@ -26,7 +28,7 @@ export default function ReviewItem({ title, createdTime, creatorName, project, s

by {creatorName}

-

{project.title}

+

{projectData.title}

{type && (
state.profile); + const memberId = profile?.id || 0; + + const [activeTab, setActiveTab] = useState<'REQUESTED' | 'APPROVED' | 'REJECTED' | 'all'>('REQUESTED'); + const [, setSearchQuery] = useState(''); + const [sortValue, setSortValue] = useState('latest'); + + const { data: workspaceReviews = [] } = useWorkspaceReviewsQuery( + workspaceId, + memberId, + activeTab !== 'all' ? activeTab : undefined + ); + + return ( +
+
+
+ {['REQUESTED', 'APPROVED', 'REJECTED', 'all'].map((tab) => ( + + ))} +
+
+ +
+ +
+ +
+ {workspaceReviews.length === 0 ? ( +
워크스페이스에 리뷰가 없습니다.
+ ) : ( + workspaceReviews.map((item) => ( + + )) + )} +
+
+ ); +} diff --git a/frontend/src/components/ReviewList/index.tsx b/frontend/src/components/ReviewList/index.tsx index 1736420..fcf8eec 100644 --- a/frontend/src/components/ReviewList/index.tsx +++ b/frontend/src/components/ReviewList/index.tsx @@ -1,99 +1,19 @@ -import { useState } from 'react'; -import ReviewItem from './ReviewItem'; -import ReviewSearchInput from './ReviewSearchInput'; -import useReviewByStatusQuery from '@/queries/reviews/useReviewByStatusQuery'; -import useProjectQuery from '@/queries/projects/useProjectQuery'; -import useAuthStore from '@/stores/useAuthStore'; -import { useParams } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; +import ProjectReviewList from './ProjectReviewList'; +import WorkspaceReviewList from './WorkspaceReviewList'; export default function ReviewList(): JSX.Element { - const { projectId } = useParams<{ projectId: string }>(); - const profile = useAuthStore((state) => state.profile); - const memberId = profile?.id || 0; + const { workspaceId } = useParams<{ workspaceId: string }>(); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const projectId = searchParams.get('projectId'); - const [activeTab, setActiveTab] = useState<'REQUESTED' | 'APPROVED' | 'REJECTED' | 'all'>('REQUESTED'); - const [, setSearchQuery] = useState(''); - const [sortValue, setSortValue] = useState('latest'); - - const { data: project } = useProjectQuery(Number(projectId), memberId); - - const { data: reviews = [] } = useReviewByStatusQuery( - Number(projectId), - memberId, - activeTab !== 'all' ? activeTab : undefined - ); - - return ( -
-
-
- - - - - - - -
-
- -
- -
- -
- {reviews.map((item) => ( - - ))} -
-
+ return projectId && Number(projectId) > 0 ? ( + + ) : ( + ); } diff --git a/frontend/src/queries/workspaces/useWorkspaceReviewsQuery.tsx b/frontend/src/queries/workspaces/useWorkspaceReviewsQuery.tsx new file mode 100644 index 0000000..2e03385 --- /dev/null +++ b/frontend/src/queries/workspaces/useWorkspaceReviewsQuery.tsx @@ -0,0 +1,15 @@ +import { getWorkspaceReviews } from '@/api/workspaceApi'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +export default function useWorkspaceReviewsQuery( + workspaceId: number, + memberId: number, + reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED', + lastReviewId?: number, + limitPage?: number +) { + return useSuspenseQuery({ + queryKey: ['workspaceReviews', workspaceId, reviewStatus, lastReviewId], + queryFn: () => getWorkspaceReviews(workspaceId, memberId, reviewStatus, lastReviewId, limitPage), + }); +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 942b140..8cb4da5 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -71,7 +71,7 @@ const router = createBrowserRouter([ ], }, { - path: `${webPath.admin()}/:workspaceId/project/:projectId?`, + path: `${webPath.admin()}/:workspaceId`, element: (
}> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 296e58b..2298cc7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -159,6 +159,7 @@ export interface ReviewRequest { // 리뷰 응답 DTO export interface ReviewResponse { reviewId: number; + projectId: number; title: string; content: string; status: 'REQUESTED' | 'APPROVED' | 'REJECTED'; @@ -175,9 +176,11 @@ export interface ReviewStatusRequest { // 리뷰 이미지 응답 DTO export interface ReviewImageResponse { - id: number; // 이미지 ID - imageTitle: string; // 이미지 파일 제목 + id: number; + imageTitle: string; status: 'PENDING' | 'IN_PROGRESS' | 'SAVE' | 'REVIEW_REQUEST' | 'COMPLETED'; + imagePath: string; + dataPath: string; } // 리뷰 디테일 응답 DTO @@ -187,6 +190,14 @@ export interface ReviewDetailResponse { content: string; reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED'; images: ReviewImageResponse[]; + createAt: string; + updateAt: string; + email: string; + profileImage: string; + nickname: string; + reviewerEmail: string; + reviewerProfileImage: string; + reviewerNickname: string; } // 프로젝트 멤버 응답 DTO export interface ProjectMemberResponse {