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 { 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<ReviewResponse[]>(`/workspaces/${workspaceId}/reviews`, {
params: {
memberId,
limitPage,
...(reviewStatus ? { reviewStatus } : {}),
...(lastReviewId ? { lastReviewId } : {}),
},
})
.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 { 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 (
<>
<Header className="fixed left-0 top-0" />
<div className="mt-16 h-[calc(100vh-64px)] w-screen">
<ResizablePanelGroup direction="horizontal">
<AdminProjectSidebar />
{!isIndexPage && <AdminProjectSidebar />}
<ResizablePanel className="flex w-full items-center">
<main className="h-full grow">
<Outlet />
</main>
</ResizablePanel>
{projectId && <AdminMenuSidebar />}
<AdminMenuSidebar />
</ResizablePanelGroup>
{isIndexPage && (
<main className="h-full w-full">
<Outlet />
</main>
)}
</div>
</>
);

View File

@ -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 (

View File

@ -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 (
<>
<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"
>
<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">
{workspaceId}
<h1
className="heading w-full cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900"
onClick={handleHeaderClick}
>
{workspaceTitle}
</h1>
<button className="p-2">
<SquarePen size={16} />
@ -52,7 +74,7 @@ export default function AdminProjectSidebar(): JSX.Element {
<button
key={project.id}
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}
</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 { 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: <Layers className="h-4 w-4 text-white" />,
};
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 (
<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">
@ -26,7 +28,7 @@ export default function ReviewItem({ title, createdTime, creatorName, project, s
<p className="mt-1 text-xs text-[#737278]">by {creatorName}</p>
<div className="mt-1 flex items-center">
<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>
{type && (
<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 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 (
<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>
return projectId && Number(projectId) > 0 ? (
<ProjectReviewList
projectId={Number(projectId)}
workspaceId={Number(workspaceId)}
/>
) : (
<WorkspaceReviewList workspaceId={Number(workspaceId)} />
);
}

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: (
<Suspense fallback={<div></div>}>
<AdminLayout />

View File

@ -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 {