From 15bf3b8d809788f740246b872cc0078534852a3d 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 21:35:16 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Feat:=20=EC=9B=8C=ED=81=AC=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=A6=AC=EB=B7=B0=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/workspaceApi.ts | 19 ++- frontend/src/components/AdminLayout/index.tsx | 14 ++- .../src/components/AdminMenuSidebar/index.tsx | 12 +- .../components/AdminProjectSidebar/index.tsx | 32 +++++- .../ReviewList/ProjectReviewList.tsx | 72 ++++++++++++ .../src/components/ReviewList/ReviewItem.tsx | 20 ++-- .../ReviewList/WorkspaceReviewList.tsx | 71 ++++++++++++ frontend/src/components/ReviewList/index.tsx | 108 +++--------------- .../workspaces/useWorkspaceReviewsQuery.tsx | 15 +++ frontend/src/router/index.tsx | 2 +- frontend/src/types/index.ts | 15 ++- 11 files changed, 258 insertions(+), 122 deletions(-) create mode 100644 frontend/src/components/ReviewList/ProjectReviewList.tsx create mode 100644 frontend/src/components/ReviewList/WorkspaceReviewList.tsx create mode 100644 frontend/src/queries/workspaces/useWorkspaceReviewsQuery.tsx 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 { From 58fd205f1eafa56178439344f6c395cb30a27d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Fri, 20 Sep 2024 06:59:03 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Chore:=20react-slick=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 63 ++++++++++++++++++++++++++++++++++++-- frontend/package.json | 2 ++ frontend/react-slick.d.ts | 4 +++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 frontend/react-slick.d.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 52433f2..facb99d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,8 @@ "react-konva": "^18.2.10", "react-resizable-panels": "^2.1.1", "react-router-dom": "^6.26.1", + "react-slick": "^0.30.2", + "slick-carousel": "^1.8.1", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "use-image": "^1.1.1", @@ -6958,6 +6960,11 @@ "node": ">=6" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -7524,6 +7531,11 @@ "node": ">= 0.8" } }, + "node_modules/enquire.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz", + "integrity": "sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -9287,6 +9299,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "peer": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9385,6 +9403,14 @@ "dev": true, "license": "MIT" }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -9520,8 +9546,7 @@ "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -11237,6 +11262,22 @@ "react-dom": ">=16.8" } }, + "node_modules/react-slick": { + "version": "0.30.2", + "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.30.2.tgz", + "integrity": "sha512-XvQJi7mRHuiU3b9irsqS9SGIgftIfdV5/tNcURTb5LdIokRA5kIIx3l4rlq2XYHfxcSntXapoRg/GxaVOM1yfg==", + "dependencies": { + "classnames": "^2.2.5", + "enquire.js": "^2.1.6", + "json2mq": "^0.2.0", + "lodash.debounce": "^4.0.8", + "resize-observer-polyfill": "^1.5.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -11478,6 +11519,11 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -11851,6 +11897,14 @@ "node": ">=8" } }, + "node_modules/slick-carousel": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz", + "integrity": "sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==", + "peerDependencies": { + "jquery": ">=1.8.0" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -12150,6 +12204,11 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index df2a22a..e6a4fec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,8 @@ "react-konva": "^18.2.10", "react-resizable-panels": "^2.1.1", "react-router-dom": "^6.26.1", + "react-slick": "^0.30.2", + "slick-carousel": "^1.8.1", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "use-image": "^1.1.1", diff --git a/frontend/react-slick.d.ts b/frontend/react-slick.d.ts new file mode 100644 index 0000000..7591eb1 --- /dev/null +++ b/frontend/react-slick.d.ts @@ -0,0 +1,4 @@ +declare module 'react-slick' { + const Slider: T; + export default Slider; +} From b3550ae5f4ca412e0ed8efd1fca7c77f5fdae0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Fri, 20 Sep 2024 07:10:52 +0900 Subject: [PATCH 3/9] =?UTF-8?q?Feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=A9=A4=EB=B2=84=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/types/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2298cc7..5c6bca4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -70,6 +70,17 @@ export interface MemberResponse { nickname: string; profileImage: string; } +export interface MemberSearchResponse { + id: number; + nickname: string; + profileImage: string; + email: string; +} +export interface WorkspaceMemberResponse { + memberId: number; + nickname: string; + profileImage: string; +} export interface WorkspaceRequest { title: string; From 745a1198d82520e2c9cfbb0c90418bf490c09763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Fri, 20 Sep 2024 07:11:32 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Feat:=20=EB=A6=AC=EB=B7=B0=EC=83=81?= =?UTF-8?q?=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReviewList/ProjectReviewList.tsx | 4 +- .../src/components/ReviewList/ReviewItem.tsx | 63 ++++++++++++------- .../ReviewList/WorkspaceReviewList.tsx | 2 + 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/ReviewList/ProjectReviewList.tsx b/frontend/src/components/ReviewList/ProjectReviewList.tsx index e2ec225..a9c3002 100644 --- a/frontend/src/components/ReviewList/ProjectReviewList.tsx +++ b/frontend/src/components/ReviewList/ProjectReviewList.tsx @@ -9,7 +9,7 @@ interface ProjectReviewListProps { workspaceId: number; } -export default function ProjectReviewList({ projectId }: ProjectReviewListProps): JSX.Element { +export default function ProjectReviewList({ projectId, workspaceId }: ProjectReviewListProps): JSX.Element { const profile = useAuthStore((state) => state.profile); const memberId = profile?.id || 0; @@ -58,6 +58,8 @@ export default function ProjectReviewList({ projectId }: ProjectReviewListProps) projectReviews.map((item) => ( = { @@ -16,34 +20,49 @@ const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Ele segmentation: , }; -export default function ReviewItem({ title, createdTime, creatorName, projectId, status, type }: ReviewItemProps) { +export default function ReviewItem({ + title, + createdTime, + creatorName, + projectId, + status, + type, + workspaceId, + reviewId, +}: 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 ( -
-
-

{title}

-

by {creatorName}

-
- -

{projectData.title}

-
- {type && ( -
- {icon} - {type.text} + +
+
+

{title}

+

by {creatorName}

+
+ +

{projectData?.title}

- )} + {type && ( +
+ {icon} + {type.text} +
+ )} +
+
+
{status}
+

Created at {createdTime}

+
-
-
{status}
-

Created at {createdTime}

-
-
+ ); } diff --git a/frontend/src/components/ReviewList/WorkspaceReviewList.tsx b/frontend/src/components/ReviewList/WorkspaceReviewList.tsx index cdae53f..4209653 100644 --- a/frontend/src/components/ReviewList/WorkspaceReviewList.tsx +++ b/frontend/src/components/ReviewList/WorkspaceReviewList.tsx @@ -57,6 +57,8 @@ export default function WorkspaceReviewList({ workspaceId }: WorkspaceReviewList workspaceReviews.map((item) => ( Date: Fri, 20 Sep 2024 07:12:37 +0900 Subject: [PATCH 5/9] =?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=B5=AC=ED=98=84,=20test=20=EB=AA=BB?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ReviewDetail/index.tsx | 176 ++++++++++++------ 1 file changed, 121 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/ReviewDetail/index.tsx b/frontend/src/components/ReviewDetail/index.tsx index c9dc42d..c0d3690 100644 --- a/frontend/src/components/ReviewDetail/index.tsx +++ b/frontend/src/components/ReviewDetail/index.tsx @@ -1,86 +1,152 @@ -import useReviewDetailQuery from '@/queries/reviews/useReviewDetailQuery'; -import useUpdateReviewQuery from '@/queries/reviews/useUpdateReviewQuery'; -import useDeleteReviewQuery from '@/queries/reviews/useDeleteReviewQuery'; +import { useState } from 'react'; import { useParams } from 'react-router-dom'; +import Slider from 'react-slick'; +import useReviewDetailQuery from '@/queries/reviews/useReviewDetailQuery'; +import useUpdateReviewStatusQuery from '@/queries/reviews/useUpdateReviewStatusQuery'; +import useProjectMembersQuery from '@/queries/projects/useProjectMembersQuery'; +import useAuthStore from '@/stores/useAuthStore'; +import { Button } from '@/components/ui/button'; +import 'slick-carousel/slick/slick.css'; +import 'slick-carousel/slick/slick-theme.css'; -export default function ReviewDetail() { +export default function ReviewDetail(): JSX.Element { const { projectId, reviewId } = useParams<{ projectId: string; reviewId: string }>(); - const memberId = 1; + const profile = useAuthStore((state) => state.profile); + const memberId = profile?.id || 0; const { data: reviewDetail } = useReviewDetailQuery(Number(projectId), Number(reviewId), memberId); - const updateReview = useUpdateReviewQuery(); - const deleteReview = useDeleteReviewQuery(); + const { data: projectMembers } = useProjectMembersQuery(Number(projectId), memberId); - const handleUpdate = () => { - updateReview.mutate({ + const updateReviewStatus = useUpdateReviewStatusQuery(); + const [activeTab, setActiveTab] = useState<'content' | 'images'>('content'); + + const handleApprove = () => { + updateReviewStatus.mutate({ projectId: Number(projectId), reviewId: Number(reviewId), memberId, - reviewData: { - title: reviewDetail.title, - content: reviewDetail.content, - imageIds: reviewDetail.images.map((image) => image.id), - }, + reviewStatus: 'APPROVED', }); }; - const handleDelete = () => { - deleteReview.mutate({ + const handleReject = () => { + updateReviewStatus.mutate({ projectId: Number(projectId), reviewId: Number(reviewId), memberId, + reviewStatus: 'REJECTED', }); }; - if (!reviewDetail) return

Loading...

; - - const { title, content, reviewStatus, images } = reviewDetail; + const settings = { + dots: true, + infinite: true, + speed: 500, + slidesToShow: 1, + slidesToScroll: 1, + }; return ( -
-
-

{title}

-
{reviewStatus}
-
- -
-

by 김용수

-

|

-

8 hours ago

+
+
+

{reviewDetail.title}

+

+ 작성자: {reviewDetail.nickname} ({reviewDetail.email}) +

+

작성일: {new Date(reviewDetail.createAt).toLocaleDateString()}

+

수정일: {new Date(reviewDetail.updateAt).toLocaleDateString()}

-
-

내용

-

{content}

-
- -
-

이미지 목록

-
    - {images.map((image) => ( -
  • +
    + {['content', 'images'].map((tab) => ( + + ))} +
    +
+ +
+ {activeTab === 'content' ? ( +

{reviewDetail.content}

+ ) : ( +
+ {reviewDetail.images.length > 0 ? ( + + {reviewDetail.images.map((image) => ( +
+ 리뷰 이미지 +
+ ))} +
+ ) : ( +

이미지가 없습니다.

+ )} +
+ )} +
+ + {reviewDetail.reviewStatus === 'APPROVED' && ( +
+

리뷰어

+
+ 리뷰어 프로필 +
+

{reviewDetail.reviewerNickname}

+

{reviewDetail.reviewerEmail}

+
+
+
+ )} + +
+

프로젝트 멤버

+
    + {projectMembers.map((member) => ( +
  • + {member.nickname} - {member.privilegeType}
  • ))}
-
- - +
+ {reviewDetail.reviewStatus !== 'APPROVED' && ( + + )} + {reviewDetail.reviewStatus !== 'REJECTED' && ( + + )}
); From c9ff589f57bd458b43b7ef631f8a5d33adf69884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Fri, 20 Sep 2024 07:13:29 +0900 Subject: [PATCH 6/9] =?UTF-8?q?Feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=93=B1=20=ED=95=A8=EC=88=98=20=EB=B0=8F?= =?UTF-8?q?=20=EC=BF=BC=EB=A6=AC=20=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/memberApi.ts | 11 +++++++++++ frontend/src/api/workspaceApi.ts | 19 ++++++++++++++++++- .../members/useSearchMembersByEmailQuery.ts | 9 +++++++++ .../workspaces/useWorkspaceMembersQuery.ts | 9 +++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/memberApi.ts create mode 100644 frontend/src/queries/members/useSearchMembersByEmailQuery.ts create mode 100644 frontend/src/queries/workspaces/useWorkspaceMembersQuery.ts diff --git a/frontend/src/api/memberApi.ts b/frontend/src/api/memberApi.ts new file mode 100644 index 0000000..c9bbca9 --- /dev/null +++ b/frontend/src/api/memberApi.ts @@ -0,0 +1,11 @@ +import api from '@/api/axiosConfig'; +import { MemberSearchResponse } from '@/types'; + +export async function searchMembersByEmail(keyword: string) { + return api + .get(`/api/members`, { + params: { keyword }, + withCredentials: true, + }) + .then(({ data }) => data); +} diff --git a/frontend/src/api/workspaceApi.ts b/frontend/src/api/workspaceApi.ts index bc45383..063036f 100644 --- a/frontend/src/api/workspaceApi.ts +++ b/frontend/src/api/workspaceApi.ts @@ -1,5 +1,11 @@ import api from '@/api/axiosConfig'; -import { WorkspaceListResponse, WorkspaceRequest, WorkspaceResponse, ReviewResponse } from '@/types'; +import { + WorkspaceListResponse, + WorkspaceRequest, + WorkspaceResponse, + ReviewResponse, + WorkspaceMemberResponse, +} from '@/types'; export async function getWorkspaceList(memberId: number, lastWorkspaceId?: number, limit?: number) { return api @@ -67,3 +73,14 @@ export async function getWorkspaceReviews( }) .then(({ data }) => data); } +export async function removeWorkspaceMember(workspaceId: number, memberId: number, targetMemberId: number) { + return api + .delete(`/workspaces/${workspaceId}/members/${targetMemberId}`, { + params: { memberId }, + }) + .then(({ data }) => data); +} + +export async function getWorkspaceMembers(workspaceId: number) { + return api.get(`/workspaces/${workspaceId}/members`).then(({ data }) => data); +} diff --git a/frontend/src/queries/members/useSearchMembersByEmailQuery.ts b/frontend/src/queries/members/useSearchMembersByEmailQuery.ts new file mode 100644 index 0000000..54137da --- /dev/null +++ b/frontend/src/queries/members/useSearchMembersByEmailQuery.ts @@ -0,0 +1,9 @@ +import { searchMembersByEmail } from '@/api/memberApi'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +export default function useSearchMembersByEmailQuery(keyword: string) { + return useSuspenseQuery({ + queryKey: ['members', keyword], + queryFn: () => searchMembersByEmail(keyword), + }); +} diff --git a/frontend/src/queries/workspaces/useWorkspaceMembersQuery.ts b/frontend/src/queries/workspaces/useWorkspaceMembersQuery.ts new file mode 100644 index 0000000..8f2f31e --- /dev/null +++ b/frontend/src/queries/workspaces/useWorkspaceMembersQuery.ts @@ -0,0 +1,9 @@ +import { getWorkspaceMembers } from '@/api/workspaceApi'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +export default function useWorkspaceMembersQuery(workspaceId: number) { + return useSuspenseQuery({ + queryKey: ['workspaceMembers', workspaceId], + queryFn: () => getWorkspaceMembers(workspaceId), + }); +} From 6ac6ddf987ef7f433255a892f8264c962e62ea2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Fri, 20 Sep 2024 07:22:17 +0900 Subject: [PATCH 7/9] =?UTF-8?q?Refactor=20:=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Header/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Header/index.tsx b/frontend/src/components/Header/index.tsx index 67053c1..58fe5b6 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, projectId } = useParams<{ workspaceId: string; projectId?: string }>(); + const { workspaceId } = useParams<{ workspaceId: string }>(); const isWorkspaceIdNaN = isNaN(Number(workspaceId)); const isHomePage = location.pathname === '/'; @@ -46,7 +46,7 @@ export default function Header({ className, ...props }: HeaderProps) { labeling admin From cd4427fd74cb1567ef2ba97739356089685e930e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Fri, 20 Sep 2024 07:32:47 +0900 Subject: [PATCH 8/9] =?UTF-8?q?Feat:=20=EB=A9=A4=EB=B2=84=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=20=EC=9E=91=EC=97=85=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...geForm.tsx => ProjectMemberManageForm.tsx} | 8 +- .../WorkspaceMemberManageForm.tsx | 125 ++++++++++++++++++ .../components/AdminMemberManage/index.tsx | 49 ++++--- .../MemberAddModal/MemberAddForm.tsx | 38 +++++- frontend/src/router/index.tsx | 6 + 5 files changed, 199 insertions(+), 27 deletions(-) rename frontend/src/components/AdminMemberManage/{AdminMemberManageForm.tsx => ProjectMemberManageForm.tsx} (93%) create mode 100644 frontend/src/components/AdminMemberManage/WorkspaceMemberManageForm.tsx diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx b/frontend/src/components/AdminMemberManage/ProjectMemberManageForm.tsx similarity index 93% rename from frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx rename to frontend/src/components/AdminMemberManage/ProjectMemberManageForm.tsx index 8fe7c5d..66f46cd 100644 --- a/frontend/src/components/AdminMemberManage/AdminMemberManageForm.tsx +++ b/frontend/src/components/AdminMemberManage/ProjectMemberManageForm.tsx @@ -29,17 +29,17 @@ const formSchema = z.object({ ), }); -export type MemberManageFormValues = z.infer; +export type ProjectMemberManageFormValues = z.infer; -interface AdminMemberManageFormProps { +interface ProjectMemberManageFormProps { members: ProjectMemberResponse[]; } -export default function AdminMemberManageForm({ members }: AdminMemberManageFormProps) { +export default function ProjectMemberManageForm({ members }: ProjectMemberManageFormProps) { const { projectId } = useParams<{ projectId: string }>(); const { mutate: updatePrivilege } = useUpdateProjectMemberPrivilegeQuery(); - const form = useForm({ + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { members: members.map((m) => ({ diff --git a/frontend/src/components/AdminMemberManage/WorkspaceMemberManageForm.tsx b/frontend/src/components/AdminMemberManage/WorkspaceMemberManageForm.tsx new file mode 100644 index 0000000..5df5b95 --- /dev/null +++ b/frontend/src/components/AdminMemberManage/WorkspaceMemberManageForm.tsx @@ -0,0 +1,125 @@ +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, FormMessage } from '../ui/form'; +import { Input } from '../ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import { WorkspaceMemberResponse } from '@/types'; +import useUpdateProjectMemberPrivilegeQuery from '@/queries/projects/useUpdateProjectMemberPrivilegeQuery'; + +type Role = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER'; + +const roles: Role[] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER']; + +const roleToStr: { [key in Role]: string } = { + ADMIN: '관리자', + MANAGER: '매니저', + EDITOR: '에디터', + VIEWER: '뷰어', +}; + +const formSchema = z.object({ + members: z.array( + z.object({ + memberId: z.number(), + nickname: z.string().nonempty('닉네임을 입력하세요.'), + role: z.enum(roles as [Role, ...Role[]], { errorMap: () => ({ message: '역할을 선택해주세요.' }) }), + }) + ), +}); + +export type WorkspaceMemberManageFormValues = z.infer; + +interface WorkspaceMemberManageFormProps { + members: WorkspaceMemberResponse[]; +} + +export default function WorkspaceMemberManageForm({ members }: WorkspaceMemberManageFormProps) { + const { workspaceId } = useParams<{ workspaceId: string }>(); + const { mutate: updatePrivilege } = useUpdateProjectMemberPrivilegeQuery(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + members: members.map((m) => ({ + memberId: m.memberId, + nickname: m.nickname, + role: m.privilegeType as Role, + })), + }, + }); + + const handleRoleChange = (memberId: number, role: Role) => { + updatePrivilege({ + workspaceId: Number(workspaceId), + memberId, + privilegeData: { + memberId, + privilegeType: role, + }, + }); + }; + + return ( +
+
+ {members.map((member, index) => ( +
+ ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/AdminMemberManage/index.tsx b/frontend/src/components/AdminMemberManage/index.tsx index 3748aa3..ef1abb3 100644 --- a/frontend/src/components/AdminMemberManage/index.tsx +++ b/frontend/src/components/AdminMemberManage/index.tsx @@ -1,34 +1,44 @@ import { useState } from 'react'; -import AdminMemberManageForm from './AdminMemberManageForm'; import { useParams } from 'react-router-dom'; -import useProjectMembersQuery from '@/queries/projects/useProjectMembersQuery'; import useAuthStore from '@/stores/useAuthStore'; +import useAddWorkspaceMemberQuery from '@/queries/workspaces/useAddWorkspaceMemberQuery'; import useAddProjectMemberQuery from '@/queries/projects/useAddProjectMemberQuery'; +import useWorkspaceMembersQuery from '@/queries/workspaces/useWorkspaceMembersQuery'; +import useProjectMembersQuery from '@/queries/projects/useProjectMembersQuery'; import MemberAddModal from '../MemberAddModal'; import { MemberAddFormValues } from '../MemberAddModal/MemberAddForm'; +import WorkspaceMemberManageForm from './WorkspaceMemberManageForm'; +import ProjectMemberManageForm from './ProjectMemberManageForm'; export default function AdminMemberManage() { - const { projectId } = useParams<{ workspaceId: string; projectId: string }>(); + const { workspaceId, projectId } = useParams<{ workspaceId?: string; projectId?: string }>(); const profile = useAuthStore((state) => state.profile); const memberId = profile?.id || 0; - const { data: members = [] } = useProjectMembersQuery(Number(projectId), memberId); - const addProjectMember = useAddProjectMemberQuery(); - 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); + if (workspaceId) { + const addWorkspaceMember = useAddWorkspaceMemberQuery(); + addWorkspaceMember.mutate({ + workspaceId: Number(workspaceId), + memberId: memberId, + newMember: { + memberId: 0, + privilegeType: data.role, + }, + }); + } else if (projectId) { + const addProjectMember = useAddProjectMemberQuery(); + addProjectMember.mutate({ + projectId: Number(projectId), + memberId: memberId, + newMember: { + memberId: 0, + privilegeType: data.role, + }, + }); + } setInviteModalOpen(false); }; @@ -39,7 +49,10 @@ export default function AdminMemberManage() {
- + {workspaceId && } + {projectId && ( + + )} ); } diff --git a/frontend/src/components/MemberAddModal/MemberAddForm.tsx b/frontend/src/components/MemberAddModal/MemberAddForm.tsx index b23745b..3031f08 100644 --- a/frontend/src/components/MemberAddModal/MemberAddForm.tsx +++ b/frontend/src/components/MemberAddModal/MemberAddForm.tsx @@ -1,10 +1,12 @@ +import { useState } from 'react'; 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 { Input } from '../ui/input'; import { Button } from '../ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import SearchInput from '../ui/search-input'; +import useSearchMembersByEmailQuery from '@/queries/members/useSearchMembersByEmailQuery'; type PrivilegeType = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER'; @@ -43,6 +45,9 @@ export default function MemberAddForm({ onSubmit }: { onSubmit: (data: MemberAdd defaultValues, }); + const [keyword, setKeyword] = useState(''); + const { data: members } = useSearchMembersByEmailQuery(keyword); + return (
이메일 - { + field.onChange(e); + setKeyword(e.target.value); + }} /> )} /> + {members && ( +
    + {members.map((member) => ( +
  • + {member.nickname} + + {member.nickname} ({member.email}) + +
  • + ))} +
+ )} + '/', browse: () => '/browse', @@ -86,6 +88,10 @@ const router = createBrowserRouter([ path: 'reviews', element: , }, + { + path: 'reviews/:projectId/:reviewId', + element: , + }, { path: 'members', element: , From 62cb3677279b8a4486d1fc634642001586e08b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Fri, 20 Sep 2024 08:21:56 +0900 Subject: [PATCH 9/9] =?UTF-8?q?Feat=20:=20admin=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=20=EB=B6=80=EB=B6=84=20=EA=B0=80=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProjectMemberManageForm.tsx | 17 ++- .../WorkspaceMemberManageForm.tsx | 129 +++--------------- .../components/AdminMemberManage/index.tsx | 28 ++-- .../MemberAddModal/MemberAddForm.tsx | 56 ++++---- 4 files changed, 63 insertions(+), 167 deletions(-) diff --git a/frontend/src/components/AdminMemberManage/ProjectMemberManageForm.tsx b/frontend/src/components/AdminMemberManage/ProjectMemberManageForm.tsx index 66f46cd..226e296 100644 --- a/frontend/src/components/AdminMemberManage/ProjectMemberManageForm.tsx +++ b/frontend/src/components/AdminMemberManage/ProjectMemberManageForm.tsx @@ -1,12 +1,13 @@ -import { useParams } from 'react-router-dom'; +import { useLocation } 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, FormMessage } from '../ui/form'; import { Input } from '../ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; -import { ProjectMemberResponse } from '@/types'; import useUpdateProjectMemberPrivilegeQuery from '@/queries/projects/useUpdateProjectMemberPrivilegeQuery'; +import useProjectMembersQuery from '@/queries/projects/useProjectMembersQuery'; +import useAuthStore from '@/stores/useAuthStore'; type Role = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER'; @@ -31,12 +32,14 @@ const formSchema = z.object({ export type ProjectMemberManageFormValues = z.infer; -interface ProjectMemberManageFormProps { - members: ProjectMemberResponse[]; -} +export default function ProjectMemberManageForm() { + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const projectId = searchParams.get('projectId'); + const profile = useAuthStore((state) => state.profile); + const memberId = profile?.id || 0; -export default function ProjectMemberManageForm({ members }: ProjectMemberManageFormProps) { - const { projectId } = useParams<{ projectId: string }>(); + const { data: members = [] } = useProjectMembersQuery(Number(projectId), memberId); const { mutate: updatePrivilege } = useUpdateProjectMemberPrivilegeQuery(); const form = useForm({ diff --git a/frontend/src/components/AdminMemberManage/WorkspaceMemberManageForm.tsx b/frontend/src/components/AdminMemberManage/WorkspaceMemberManageForm.tsx index 5df5b95..22d1779 100644 --- a/frontend/src/components/AdminMemberManage/WorkspaceMemberManageForm.tsx +++ b/frontend/src/components/AdminMemberManage/WorkspaceMemberManageForm.tsx @@ -1,125 +1,30 @@ 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, FormMessage } from '../ui/form'; -import { Input } from '../ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; -import { WorkspaceMemberResponse } from '@/types'; -import useUpdateProjectMemberPrivilegeQuery from '@/queries/projects/useUpdateProjectMemberPrivilegeQuery'; +import useWorkspaceMembersQuery from '@/queries/workspaces/useWorkspaceMembersQuery'; -type Role = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER'; - -const roles: Role[] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER']; - -const roleToStr: { [key in Role]: string } = { - ADMIN: '관리자', - MANAGER: '매니저', - EDITOR: '에디터', - VIEWER: '뷰어', -}; - -const formSchema = z.object({ - members: z.array( - z.object({ - memberId: z.number(), - nickname: z.string().nonempty('닉네임을 입력하세요.'), - role: z.enum(roles as [Role, ...Role[]], { errorMap: () => ({ message: '역할을 선택해주세요.' }) }), - }) - ), -}); - -export type WorkspaceMemberManageFormValues = z.infer; - -interface WorkspaceMemberManageFormProps { - members: WorkspaceMemberResponse[]; -} - -export default function WorkspaceMemberManageForm({ members }: WorkspaceMemberManageFormProps) { +export default function WorkspaceMemberManageForm() { const { workspaceId } = useParams<{ workspaceId: string }>(); - const { mutate: updatePrivilege } = useUpdateProjectMemberPrivilegeQuery(); - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - members: members.map((m) => ({ - memberId: m.memberId, - nickname: m.nickname, - role: m.privilegeType as Role, - })), - }, - }); - - const handleRoleChange = (memberId: number, role: Role) => { - updatePrivilege({ - workspaceId: Number(workspaceId), - memberId, - privilegeData: { - memberId, - privilegeType: role, - }, - }); - }; + const { data: members = [] } = useWorkspaceMembersQuery(Number(workspaceId)); return ( - -
- {members.map((member, index) => ( +
+ {members.length === 0 ? ( +
워크스페이스에 멤버가 없습니다.
+ ) : ( + members.map((member) => (
- ( - - - - - - - )} - /> - - ( - - - - - - - )} + {member.nickname} + {member.nickname}
- ))} -
- + )) + )} +
); } diff --git a/frontend/src/components/AdminMemberManage/index.tsx b/frontend/src/components/AdminMemberManage/index.tsx index ef1abb3..d493c54 100644 --- a/frontend/src/components/AdminMemberManage/index.tsx +++ b/frontend/src/components/AdminMemberManage/index.tsx @@ -1,35 +1,35 @@ import { useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, useLocation } from 'react-router-dom'; import useAuthStore from '@/stores/useAuthStore'; import useAddWorkspaceMemberQuery from '@/queries/workspaces/useAddWorkspaceMemberQuery'; import useAddProjectMemberQuery from '@/queries/projects/useAddProjectMemberQuery'; -import useWorkspaceMembersQuery from '@/queries/workspaces/useWorkspaceMembersQuery'; -import useProjectMembersQuery from '@/queries/projects/useProjectMembersQuery'; import MemberAddModal from '../MemberAddModal'; import { MemberAddFormValues } from '../MemberAddModal/MemberAddForm'; import WorkspaceMemberManageForm from './WorkspaceMemberManageForm'; import ProjectMemberManageForm from './ProjectMemberManageForm'; export default function AdminMemberManage() { - const { workspaceId, projectId } = useParams<{ workspaceId?: string; projectId?: string }>(); + const { workspaceId } = useParams<{ workspaceId: string }>(); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const projectId = searchParams.get('projectId'); + const profile = useAuthStore((state) => state.profile); const memberId = profile?.id || 0; + const addWorkspaceMember = useAddWorkspaceMemberQuery(); + const addProjectMember = useAddProjectMemberQuery(); + const [, setInviteModalOpen] = useState(false); const handleMemberInvite = (data: MemberAddFormValues) => { if (workspaceId) { - const addWorkspaceMember = useAddWorkspaceMemberQuery(); addWorkspaceMember.mutate({ workspaceId: Number(workspaceId), memberId: memberId, - newMember: { - memberId: 0, - privilegeType: data.role, - }, + newMemberId: data.memberId, }); - } else if (projectId) { - const addProjectMember = useAddProjectMemberQuery(); + } else if (projectId && Number(projectId) > 0) { addProjectMember.mutate({ projectId: Number(projectId), memberId: memberId, @@ -49,10 +49,8 @@ export default function AdminMemberManage() {
- {workspaceId && } - {projectId && ( - - )} + {workspaceId && } + {projectId && } ); } diff --git a/frontend/src/components/MemberAddModal/MemberAddForm.tsx b/frontend/src/components/MemberAddModal/MemberAddForm.tsx index 3031f08..a2b329a 100644 --- a/frontend/src/components/MemberAddModal/MemberAddForm.tsx +++ b/frontend/src/components/MemberAddModal/MemberAddForm.tsx @@ -20,22 +20,14 @@ const privilegeTypeToStr: { [key in PrivilegeType]: string } = { }; const formSchema = z.object({ - email: z - .string() - .email({ - message: '올바른 이메일 형식을 입력해주세요.', - }) - .max(40) - .min(1, { - message: '초대할 멤버의 이메일 주소를 입력해주세요.', - }), + memberId: z.number().nonnegative({ message: '멤버를 선택하세요.' }), role: z.enum(privilegeTypes), }); export type MemberAddFormValues = z.infer; const defaultValues: Partial = { - email: '', + memberId: 0, role: undefined, }; @@ -48,43 +40,40 @@ export default function MemberAddForm({ onSubmit }: { onSubmit: (data: MemberAdd const [keyword, setKeyword] = useState(''); const { data: members } = useSearchMembersByEmailQuery(keyword); + const handleMemberSelect = (memberId: number) => { + form.setValue('memberId', memberId); + }; + return (
- ( - - 이메일 - - { - field.onChange(e); - setKeyword(e.target.value); - }} - /> - - - - )} - /> + + 이메일 + + setKeyword(e.target.value)} + /> + + + + {members && (
    {members.map((member) => (
  • handleMemberSelect(member.id)} > {member.nickname} {member.nickname} ({member.email}) @@ -124,10 +113,11 @@ export default function MemberAddForm({ onSubmit }: { onSubmit: (data: MemberAdd )} /> +