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 index 031e763..6f10fc1 100644 --- a/frontend/src/api/reviewApi.ts +++ b/frontend/src/api/reviewApi.ts @@ -50,11 +50,21 @@ export async function updateReviewStatus(projectId: number, reviewId: number, me .then(({ data }) => data); } -// 리뷰 상태별 조회 -export async function getReviewByStatus(projectId: number, memberId: number, reviewStatus: string) { +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, reviewStatus }, + 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 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/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/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 {