Feat: 워크스페이스 리뷰 전체 조회 구현

This commit is contained in:
정현조 2024-09-19 21:35:16 +09:00
parent 4b48718cbe
commit 15bf3b8d80
11 changed files with 258 additions and 122 deletions

View File

@ -1,5 +1,5 @@
import api from '@/api/axiosConfig'; 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) { export async function getWorkspaceList(memberId: number, lastWorkspaceId?: number, limit?: number) {
return api return api
@ -49,10 +49,21 @@ export async function addWorkspaceMember(workspaceId: number, memberId: number,
.then(({ data }) => data); .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 return api
.delete(`/workspaces/${workspaceId}/members/${targetMemberId}`, { .get<ReviewResponse[]>(`/workspaces/${workspaceId}/reviews`, {
params: { memberId }, params: {
memberId,
limitPage,
...(reviewStatus ? { reviewStatus } : {}),
...(lastReviewId ? { lastReviewId } : {}),
},
}) })
.then(({ data }) => data); .then(({ data }) => data);
} }

View File

@ -1,25 +1,31 @@
import { Outlet, useParams } from 'react-router-dom'; import { Outlet, useMatch } from 'react-router-dom';
import Header from '../Header'; import Header from '../Header';
import { ResizablePanelGroup, ResizablePanel } from '../ui/resizable'; import { ResizablePanelGroup, ResizablePanel } from '../ui/resizable';
import AdminProjectSidebar from '../AdminProjectSidebar'; import AdminProjectSidebar from '../AdminProjectSidebar';
import AdminMenuSidebar from '../AdminMenuSidebar'; import AdminMenuSidebar from '../AdminMenuSidebar';
export default function AdminLayout() { export default function AdminLayout() {
const { projectId } = useParams<{ projectId?: string }>(); const isIndexPage = useMatch({ path: '/admin/:workspaceId', end: true });
return ( return (
<> <>
<Header className="fixed left-0 top-0" /> <Header className="fixed left-0 top-0" />
<div className="mt-16 h-[calc(100vh-64px)] w-screen"> <div className="mt-16 h-[calc(100vh-64px)] w-screen">
<ResizablePanelGroup direction="horizontal"> <ResizablePanelGroup direction="horizontal">
<AdminProjectSidebar /> {!isIndexPage && <AdminProjectSidebar />}
<ResizablePanel className="flex w-full items-center"> <ResizablePanel className="flex w-full items-center">
<main className="h-full grow"> <main className="h-full grow">
<Outlet /> <Outlet />
</main> </main>
</ResizablePanel> </ResizablePanel>
{projectId && <AdminMenuSidebar />} <AdminMenuSidebar />
</ResizablePanelGroup> </ResizablePanelGroup>
{isIndexPage && (
<main className="h-full w-full">
<Outlet />
</main>
)}
</div> </div>
</> </>
); );

View File

@ -3,11 +3,17 @@ import { cn } from '@/lib/utils';
export default function AdminMenuSidebar() { export default function AdminMenuSidebar() {
const navigate = useNavigate(); const navigate = useNavigate();
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>(); const { workspaceId } = useParams<{ workspaceId: string }>();
const menuItems = [ 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 ( return (

View File

@ -1,20 +1,24 @@
import { ResizablePanel, ResizableHandle } from '../ui/resizable'; 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 { SquarePen } from 'lucide-react';
import useProjectListQuery from '@/queries/projects/useProjectListQuery'; import useProjectListQuery from '@/queries/projects/useProjectListQuery';
import useCreateProjectQuery from '@/queries/projects/useCreateProjectQuery'; import useCreateProjectQuery from '@/queries/projects/useCreateProjectQuery';
import useWorkspaceQuery from '@/queries/workspaces/useWorkspaceQuery';
import { ProjectRequest } from '@/types'; import { ProjectRequest } from '@/types';
import useAuthStore from '@/stores/useAuthStore'; import useAuthStore from '@/stores/useAuthStore';
import ProjectCreateModal from '../ProjectCreateModal'; import ProjectCreateModal from '../ProjectCreateModal';
export default function AdminProjectSidebar(): JSX.Element { export default function AdminProjectSidebar(): JSX.Element {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { workspaceId } = useParams<{ workspaceId: string }>(); const { workspaceId } = useParams<{ workspaceId: string }>();
const profile = useAuthStore((state) => state.profile); const profile = useAuthStore((state) => state.profile);
const memberId = profile?.id || 0; 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 projects = projectsResponse?.workspaceResponses ?? [];
const createProject = useCreateProjectQuery(); 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 ( return (
<> <>
<ResizablePanel <ResizablePanel
@ -36,8 +55,11 @@ export default function AdminProjectSidebar(): JSX.Element {
className="flex h-full flex-col border-r border-gray-200 bg-gray-100" className="flex h-full flex-col border-r border-gray-200 bg-gray-100"
> >
<header className="flex w-full items-center justify-between gap-2 border-b border-gray-200 p-4"> <header className="flex w-full items-center justify-between gap-2 border-b border-gray-200 p-4">
<h1 className="heading w-full overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900"> <h1
{workspaceId} className="heading w-full cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900"
onClick={handleHeaderClick}
>
{workspaceTitle}
</h1> </h1>
<button className="p-2"> <button className="p-2">
<SquarePen size={16} /> <SquarePen size={16} />
@ -52,7 +74,7 @@ export default function AdminProjectSidebar(): JSX.Element {
<button <button
key={project.id} key={project.id}
className="body cursor-pointer rounded-md px-3 py-2 text-left hover:bg-gray-200" className="body cursor-pointer rounded-md px-3 py-2 text-left hover:bg-gray-200"
onClick={() => navigate(`/admin/${workspaceId}/project/${project.id}`)} onClick={() => handleProjectClick(project.id)}
> >
{project.title} {project.title}
</button> </button>

View File

@ -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 (
<div className="relative w-full">
<div className="relative w-full px-4">
<div className="flex w-full items-center border-b-[0.67px] border-solid border-[#dcdcde]">
{['REQUESTED', 'APPROVED', 'REJECTED', 'all'].map((tab) => (
<button
key={tab}
className={`flex h-12 w-[100px] items-center justify-between px-3 ${
activeTab === tab ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
}`}
onClick={() => setActiveTab(tab as typeof activeTab)}
>
<span className={`text-sm ${activeTab === tab ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
{tab === 'REQUESTED' ? '요청' : tab === 'APPROVED' ? '승인' : tab === 'REJECTED' ? '거부' : '전체'}
</span>
</button>
))}
</div>
</div>
<div className="relative w-full px-4">
<ReviewSearchInput
onSearchChange={setSearchQuery}
onSortChange={setSortValue}
sortValue={sortValue}
/>
</div>
<div className="relative w-full overflow-y-auto px-4">
{projectReviews.length === 0 ? (
<div className="py-4 text-center"> .</div>
) : (
projectReviews.map((item) => (
<ReviewItem
key={item.reviewId}
title={item.title}
createdTime={item.createAt}
creatorName={item.nickname}
projectId={item.projectId}
status={item.status}
/>
))
)}
</div>
</div>
);
}

View File

@ -1,13 +1,13 @@
import { Briefcase, Tag, Box, Layers } from 'lucide-react'; 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 { interface ReviewItemProps {
title: string; title: string;
createdTime: string; createdTime: string;
creatorName: string; creatorName: string;
project: ProjectResponse; projectId: number;
status: string; status: 'REQUESTED' | 'APPROVED' | 'REJECTED';
type: { text: 'classification' | 'detection' | 'segmentation'; color: string }; type?: { text: 'classification' | 'detection' | 'segmentation'; color: string };
} }
const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Element> = { const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Element> = {
@ -16,9 +16,11 @@ const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Ele
segmentation: <Layers className="h-4 w-4 text-white" />, segmentation: <Layers className="h-4 w-4 text-white" />,
}; };
export default function ReviewItem({ title, createdTime, creatorName, project, status, type }: ReviewItemProps) { export default function ReviewItem({ title, createdTime, creatorName, projectId, status, type }: ReviewItemProps) {
const icon = typeIcons[project.projectType]; 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 ( return (
<div className="flex h-[100px] w-full items-center justify-between border-b-[0.67px] border-[#ececef] bg-[#fbfafd] p-4"> <div className="flex h-[100px] w-full items-center justify-between border-b-[0.67px] border-[#ececef] bg-[#fbfafd] p-4">
<div className="flex flex-col"> <div className="flex flex-col">
@ -26,7 +28,7 @@ export default function ReviewItem({ title, createdTime, creatorName, project, s
<p className="mt-1 text-xs text-[#737278]">by {creatorName}</p> <p className="mt-1 text-xs text-[#737278]">by {creatorName}</p>
<div className="mt-1 flex items-center"> <div className="mt-1 flex items-center">
<Briefcase className="h-3 w-3 text-[#737278]" /> <Briefcase className="h-3 w-3 text-[#737278]" />
<p className="ml-1 text-xs text-[#737278]">{project.title}</p> <p className="ml-1 text-xs text-[#737278]">{projectData.title}</p>
</div> </div>
{type && ( {type && (
<div <div

View File

@ -0,0 +1,71 @@
import { useState } from 'react';
import ReviewItem from './ReviewItem';
import ReviewSearchInput from './ReviewSearchInput';
import useWorkspaceReviewsQuery from '@/queries/workspaces/useWorkspaceReviewsQuery';
import useAuthStore from '@/stores/useAuthStore';
interface WorkspaceReviewListProps {
workspaceId: number;
}
export default function WorkspaceReviewList({ workspaceId }: WorkspaceReviewListProps): 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: workspaceReviews = [] } = useWorkspaceReviewsQuery(
workspaceId,
memberId,
activeTab !== 'all' ? activeTab : undefined
);
return (
<div className="relative w-full">
<div className="relative w-full px-4">
<div className="flex w-full items-center border-b-[0.67px] border-solid border-[#dcdcde]">
{['REQUESTED', 'APPROVED', 'REJECTED', 'all'].map((tab) => (
<button
key={tab}
className={`flex h-12 w-[100px] items-center justify-between px-3 ${
activeTab === tab ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
}`}
onClick={() => setActiveTab(tab as typeof activeTab)}
>
<span className={`text-sm ${activeTab === tab ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
{tab === 'REQUESTED' ? '요청' : tab === 'APPROVED' ? '승인' : tab === 'REJECTED' ? '거부' : '전체'}
</span>
</button>
))}
</div>
</div>
<div className="relative w-full px-4">
<ReviewSearchInput
onSearchChange={setSearchQuery}
onSortChange={setSortValue}
sortValue={sortValue}
/>
</div>
<div className="relative w-full overflow-y-auto px-4">
{workspaceReviews.length === 0 ? (
<div className="py-4 text-center"> .</div>
) : (
workspaceReviews.map((item) => (
<ReviewItem
key={item.reviewId}
title={item.title}
createdTime={item.createAt}
creatorName={item.nickname}
projectId={item.projectId}
status={item.status}
/>
))
)}
</div>
</div>
);
}

View File

@ -1,99 +1,19 @@
import { useState } from 'react'; import { useLocation, useParams } from 'react-router-dom';
import ReviewItem from './ReviewItem'; import ProjectReviewList from './ProjectReviewList';
import ReviewSearchInput from './ReviewSearchInput'; import WorkspaceReviewList from './WorkspaceReviewList';
import useReviewByStatusQuery from '@/queries/reviews/useReviewByStatusQuery';
import useProjectQuery from '@/queries/projects/useProjectQuery';
import useAuthStore from '@/stores/useAuthStore';
import { useParams } from 'react-router-dom';
export default function ReviewList(): JSX.Element { export default function ReviewList(): JSX.Element {
const { projectId } = useParams<{ projectId: string }>(); const { workspaceId } = useParams<{ workspaceId: string }>();
const profile = useAuthStore((state) => state.profile); const location = useLocation();
const memberId = profile?.id || 0; const searchParams = new URLSearchParams(location.search);
const projectId = searchParams.get('projectId');
const [activeTab, setActiveTab] = useState<'REQUESTED' | 'APPROVED' | 'REJECTED' | 'all'>('REQUESTED'); return projectId && Number(projectId) > 0 ? (
const [, setSearchQuery] = useState(''); <ProjectReviewList
const [sortValue, setSortValue] = useState('latest'); projectId={Number(projectId)}
workspaceId={Number(workspaceId)}
const { data: project } = useProjectQuery(Number(projectId), memberId); />
) : (
const { data: reviews = [] } = useReviewByStatusQuery( <WorkspaceReviewList workspaceId={Number(workspaceId)} />
Number(projectId),
memberId,
activeTab !== 'all' ? activeTab : undefined
);
return (
<div className="relative w-full">
<div className="relative w-full px-4">
<div className="flex w-full items-center border-b-[0.67px] border-solid border-[#dcdcde]">
<button
className={`flex h-12 w-[100px] items-center justify-between px-3 ${activeTab === 'REQUESTED' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''}`}
onClick={() => setActiveTab('REQUESTED')}
>
<span className={`text-sm ${activeTab === 'REQUESTED' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
</span>
</button>
<button
className={`flex h-12 w-[100px] items-center justify-between px-3 ${activeTab === 'APPROVED' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''}`}
onClick={() => setActiveTab('APPROVED')}
>
<span className={`text-sm ${activeTab === 'APPROVED' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
</span>
</button>
<button
className={`flex h-12 w-[100px] items-center justify-between px-3 ${activeTab === 'REJECTED' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''}`}
onClick={() => setActiveTab('REJECTED')}
>
<span className={`text-sm ${activeTab === 'REJECTED' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
</span>
</button>
<button
className={`flex h-12 w-[100px] items-center justify-between px-3 ${activeTab === 'all' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''}`}
onClick={() => setActiveTab('all')}
>
<span className={`text-sm ${activeTab === 'all' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
</span>
</button>
</div>
</div>
<div className="relative w-full px-4">
<ReviewSearchInput
onSearchChange={setSearchQuery}
onSortChange={setSortValue}
sortValue={sortValue}
/>
</div>
<div className="relative w-full overflow-y-auto px-4">
{reviews.map((item) => (
<ReviewItem
key={item.reviewId}
title={item.title}
createdTime={item.createAt}
creatorName={item.nickname}
project={project}
status={item.status}
type={{
text: project.projectType,
color:
project.projectType === 'classification'
? '#a2eeef'
: project.projectType === 'detection'
? '#d4c5f9'
: '#f9c5d4',
}}
/>
))}
</div>
</div>
); );
} }

View File

@ -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),
});
}

View File

@ -71,7 +71,7 @@ const router = createBrowserRouter([
], ],
}, },
{ {
path: `${webPath.admin()}/:workspaceId/project/:projectId?`, path: `${webPath.admin()}/:workspaceId`,
element: ( element: (
<Suspense fallback={<div></div>}> <Suspense fallback={<div></div>}>
<AdminLayout /> <AdminLayout />

View File

@ -159,6 +159,7 @@ export interface ReviewRequest {
// 리뷰 응답 DTO // 리뷰 응답 DTO
export interface ReviewResponse { export interface ReviewResponse {
reviewId: number; reviewId: number;
projectId: number;
title: string; title: string;
content: string; content: string;
status: 'REQUESTED' | 'APPROVED' | 'REJECTED'; status: 'REQUESTED' | 'APPROVED' | 'REJECTED';
@ -175,9 +176,11 @@ export interface ReviewStatusRequest {
// 리뷰 이미지 응답 DTO // 리뷰 이미지 응답 DTO
export interface ReviewImageResponse { export interface ReviewImageResponse {
id: number; // 이미지 ID id: number;
imageTitle: string; // 이미지 파일 제목 imageTitle: string;
status: 'PENDING' | 'IN_PROGRESS' | 'SAVE' | 'REVIEW_REQUEST' | 'COMPLETED'; status: 'PENDING' | 'IN_PROGRESS' | 'SAVE' | 'REVIEW_REQUEST' | 'COMPLETED';
imagePath: string;
dataPath: string;
} }
// 리뷰 디테일 응답 DTO // 리뷰 디테일 응답 DTO
@ -187,6 +190,14 @@ export interface ReviewDetailResponse {
content: string; content: string;
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED'; reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED';
images: ReviewImageResponse[]; images: ReviewImageResponse[];
createAt: string;
updateAt: string;
email: string;
profileImage: string;
nickname: string;
reviewerEmail: string;
reviewerProfileImage: string;
reviewerNickname: string;
} }
// 프로젝트 멤버 응답 DTO // 프로젝트 멤버 응답 DTO
export interface ProjectMemberResponse { export interface ProjectMemberResponse {