Merge branch 'fe/refactor/admin-page' into 'fe/develop'
Refactor: admin 페이지 부분 가완성 See merge request s11-s-project/S11P21S002!101
This commit is contained in:
commit
f440c1493c
63
frontend/package-lock.json
generated
63
frontend/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
4
frontend/react-slick.d.ts
vendored
Normal file
4
frontend/react-slick.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module 'react-slick' {
|
||||
const Slider: T;
|
||||
export default Slider;
|
||||
}
|
11
frontend/src/api/memberApi.ts
Normal file
11
frontend/src/api/memberApi.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { MemberSearchResponse } from '@/types';
|
||||
|
||||
export async function searchMembersByEmail(keyword: string) {
|
||||
return api
|
||||
.get<MemberSearchResponse[]>(`/api/members`, {
|
||||
params: { keyword },
|
||||
withCredentials: true,
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
@ -1,5 +1,11 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { WorkspaceListResponse, WorkspaceRequest, WorkspaceResponse } from '@/types';
|
||||
import {
|
||||
WorkspaceListResponse,
|
||||
WorkspaceRequest,
|
||||
WorkspaceResponse,
|
||||
ReviewResponse,
|
||||
WorkspaceMemberResponse,
|
||||
} from '@/types';
|
||||
|
||||
export async function getWorkspaceList(memberId: number, lastWorkspaceId?: number, limit?: number) {
|
||||
return api
|
||||
@ -49,6 +55,24 @@ export async function addWorkspaceMember(workspaceId: number, memberId: number,
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getWorkspaceReviews(
|
||||
workspaceId: number,
|
||||
memberId: number,
|
||||
reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED',
|
||||
lastReviewId?: number,
|
||||
limitPage: number = 10
|
||||
) {
|
||||
return api
|
||||
.get<ReviewResponse[]>(`/workspaces/${workspaceId}/reviews`, {
|
||||
params: {
|
||||
memberId,
|
||||
limitPage,
|
||||
...(reviewStatus ? { reviewStatus } : {}),
|
||||
...(lastReviewId ? { lastReviewId } : {}),
|
||||
},
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
export async function removeWorkspaceMember(workspaceId: number, memberId: number, targetMemberId: number) {
|
||||
return api
|
||||
.delete(`/workspaces/${workspaceId}/members/${targetMemberId}`, {
|
||||
@ -56,3 +80,7 @@ export async function removeWorkspaceMember(workspaceId: number, memberId: numbe
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function getWorkspaceMembers(workspaceId: number) {
|
||||
return api.get<WorkspaceMemberResponse[]>(`/workspaces/${workspaceId}/members`).then(({ data }) => data);
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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';
|
||||
|
||||
@ -29,17 +30,19 @@ const formSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export type MemberManageFormValues = z.infer<typeof formSchema>;
|
||||
export type ProjectMemberManageFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface AdminMemberManageFormProps {
|
||||
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 AdminMemberManageForm({ members }: AdminMemberManageFormProps) {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { data: members = [] } = useProjectMembersQuery(Number(projectId), memberId);
|
||||
const { mutate: updatePrivilege } = useUpdateProjectMemberPrivilegeQuery();
|
||||
|
||||
const form = useForm<MemberManageFormValues>({
|
||||
const form = useForm<ProjectMemberManageFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
members: members.map((m) => ({
|
@ -0,0 +1,30 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import useWorkspaceMembersQuery from '@/queries/workspaces/useWorkspaceMembersQuery';
|
||||
|
||||
export default function WorkspaceMemberManageForm() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
|
||||
const { data: members = [] } = useWorkspaceMembersQuery(Number(workspaceId));
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{members.length === 0 ? (
|
||||
<div className="py-4 text-center">워크스페이스에 멤버가 없습니다.</div>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<div
|
||||
key={member.memberId}
|
||||
className="flex items-center gap-4 border-b pb-2"
|
||||
>
|
||||
<img
|
||||
src={member.profileImage}
|
||||
alt={member.nickname}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<span>{member.nickname}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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 { useParams, useLocation } from 'react-router-dom';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import useAddWorkspaceMemberQuery from '@/queries/workspaces/useAddWorkspaceMemberQuery';
|
||||
import useAddProjectMemberQuery from '@/queries/projects/useAddProjectMemberQuery';
|
||||
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 } = 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 { data: members = [] } = useProjectMembersQuery(Number(projectId), memberId);
|
||||
const addWorkspaceMember = useAddWorkspaceMemberQuery();
|
||||
const addProjectMember = useAddProjectMemberQuery();
|
||||
|
||||
const [, setInviteModalOpen] = useState(false);
|
||||
|
||||
const handleMemberInvite = (data: MemberAddFormValues) => {
|
||||
if (workspaceId) {
|
||||
addWorkspaceMember.mutate({
|
||||
workspaceId: Number(workspaceId),
|
||||
memberId: memberId,
|
||||
newMemberId: data.memberId,
|
||||
});
|
||||
} else if (projectId && Number(projectId) > 0) {
|
||||
addProjectMember.mutate({
|
||||
projectId: Number(projectId),
|
||||
memberId: memberId,
|
||||
newMember: {
|
||||
// Todo : 멤버 id로 수정하는 로직 수정해야한다.
|
||||
// memberId: data.email,
|
||||
memberId: 0,
|
||||
privilegeType: data.role,
|
||||
},
|
||||
});
|
||||
console.log('Invited:', data);
|
||||
}
|
||||
setInviteModalOpen(false);
|
||||
};
|
||||
|
||||
@ -39,7 +49,8 @@ export default function AdminMemberManage() {
|
||||
<MemberAddModal onSubmit={handleMemberInvite} />
|
||||
</header>
|
||||
|
||||
<AdminMemberManageForm members={members} />
|
||||
{workspaceId && <WorkspaceMemberManageForm />}
|
||||
{projectId && <ProjectMemberManageForm />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
|
@ -7,7 +7,7 @@ export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
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
|
||||
</Link>
|
||||
<Link
|
||||
to={`/admin/${workspaceId}/project/${projectId ?? ''}`}
|
||||
to={`/admin/${workspaceId}}`}
|
||||
className={cn('text-color-text-default-default', 'font-body', 'text-sm sm:text-base md:text-lg')}
|
||||
>
|
||||
admin
|
||||
|
@ -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';
|
||||
|
||||
@ -18,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<typeof formSchema>;
|
||||
|
||||
const defaultValues: Partial<MemberAddFormValues> = {
|
||||
email: '',
|
||||
memberId: 0,
|
||||
role: undefined,
|
||||
};
|
||||
|
||||
@ -43,29 +37,52 @@ export default function MemberAddForm({ onSubmit }: { onSubmit: (data: MemberAdd
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const { data: members } = useSearchMembersByEmailQuery(keyword);
|
||||
|
||||
const handleMemberSelect = (memberId: number) => {
|
||||
form.setValue('memberId', memberId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
<FormField
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="body-strong">이메일</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="초대할 멤버의 이메일 주소를 입력해주세요."
|
||||
maxLength={40}
|
||||
{...field}
|
||||
<SearchInput
|
||||
placeholder="초대할 멤버의 이메일을 검색하세요."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
{members && (
|
||||
<ul className="mt-2">
|
||||
{members.map((member) => (
|
||||
<li
|
||||
key={member.id}
|
||||
className={`cursor-pointer py-1 ${form.getValues('memberId') === member.id ? 'bg-gray-200' : ''}`}
|
||||
onClick={() => handleMemberSelect(member.id)}
|
||||
>
|
||||
<img
|
||||
src={member.profileImage}
|
||||
alt={member.nickname}
|
||||
className="inline h-8 w-8 rounded-full"
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{member.nickname} ({member.email})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
name="role"
|
||||
control={form.control}
|
||||
@ -96,10 +113,11 @@ export default function MemberAddForm({ onSubmit }: { onSubmit: (data: MemberAdd
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outlinePrimary"
|
||||
disabled={!form.formState.isValid}
|
||||
disabled={!form.formState.isValid || !form.getValues('memberId')}
|
||||
>
|
||||
멤버 초대하기
|
||||
</Button>
|
||||
|
@ -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 <p>Loading...</p>;
|
||||
|
||||
const { title, content, reviewStatus, images } = reviewDetail;
|
||||
const settings = {
|
||||
dots: true,
|
||||
infinite: true,
|
||||
speed: 500,
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 bg-white p-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-[#333238]">{title}</h1>
|
||||
<div className="rounded-full bg-[#cbe2f9] px-3 py-0.5 text-xs text-[#0b5cad]">{reviewStatus}</div>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-[#737278]">by 김용수</p>
|
||||
<p className="text-sm text-[#737278]">|</p>
|
||||
<p className="text-sm text-[#737278]">8 hours ago</p>
|
||||
<div className="review-detail-container p-4">
|
||||
<div className="header mb-4">
|
||||
<h1 className="text-2xl font-bold">{reviewDetail.title}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
작성자: {reviewDetail.nickname} ({reviewDetail.email})
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">작성일: {new Date(reviewDetail.createAt).toLocaleDateString()}</p>
|
||||
<p className="text-sm text-gray-500">수정일: {new Date(reviewDetail.updateAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b pb-4">
|
||||
<h2 className="text-xl font-semibold">내용</h2>
|
||||
<p className="mt-2 text-sm text-[#333238]">{content}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl font-semibold">이미지 목록</h2>
|
||||
<ul className="mt-2 list-inside list-disc">
|
||||
{images.map((image) => (
|
||||
<li
|
||||
key={image.id}
|
||||
className="text-sm text-[#737278]"
|
||||
<div className="relative w-full px-4">
|
||||
<div className="flex w-full items-center border-b-[0.67px] border-solid border-[#dcdcde]">
|
||||
{['content', 'images'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`flex h-12 w-[100px] items-center justify-center px-3 ${
|
||||
activeTab === tab ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab as 'content' | 'images')}
|
||||
>
|
||||
{image.imageTitle} (status: {image.status})
|
||||
<span className={`text-sm ${activeTab === tab ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
|
||||
{tab === 'content' ? '내용' : '이미지'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content mt-4">
|
||||
{activeTab === 'content' ? (
|
||||
<p className="text-gray-700">{reviewDetail.content}</p>
|
||||
) : (
|
||||
<div className="images mt-4">
|
||||
{reviewDetail.images.length > 0 ? (
|
||||
<Slider {...settings}>
|
||||
{reviewDetail.images.map((image) => (
|
||||
<div key={image.id}>
|
||||
<img
|
||||
src={image.imagePath}
|
||||
alt="리뷰 이미지"
|
||||
className="h-auto w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Slider>
|
||||
) : (
|
||||
<p className="text-gray-500">이미지가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{reviewDetail.reviewStatus === 'APPROVED' && (
|
||||
<div className="reviewer-info mt-6">
|
||||
<h2 className="text-lg font-semibold">리뷰어</h2>
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={reviewDetail.reviewerProfileImage}
|
||||
alt="리뷰어 프로필"
|
||||
className="h-10 w-10 rounded-full"
|
||||
/>
|
||||
<div className="ml-4">
|
||||
<p className="font-bold">{reviewDetail.reviewerNickname}</p>
|
||||
<p className="text-gray-500">{reviewDetail.reviewerEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="meta-info mt-6">
|
||||
<h2 className="text-lg font-semibold">프로젝트 멤버</h2>
|
||||
<ul className="list-disc pl-6">
|
||||
{projectMembers.map((member) => (
|
||||
<li
|
||||
key={member.memberId}
|
||||
className="text-gray-700"
|
||||
>
|
||||
{member.nickname} - {member.privilegeType}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
<div className="actions mt-6 flex justify-end space-x-2">
|
||||
{reviewDetail.reviewStatus !== 'APPROVED' && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
수정하기
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="rounded-lg bg-red-500 px-4 py-2 text-white hover:bg-red-600"
|
||||
승인
|
||||
</Button>
|
||||
)}
|
||||
{reviewDetail.reviewStatus !== 'REJECTED' && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleReject}
|
||||
>
|
||||
삭제하기
|
||||
</button>
|
||||
거부
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
74
frontend/src/components/ReviewList/ProjectReviewList.tsx
Normal file
74
frontend/src/components/ReviewList/ProjectReviewList.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
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, workspaceId }: 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}
|
||||
workspaceId={workspaceId}
|
||||
reviewId={item.reviewId}
|
||||
title={item.title}
|
||||
createdTime={item.createAt}
|
||||
creatorName={item.nickname}
|
||||
projectId={item.projectId}
|
||||
status={item.status}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,13 +1,17 @@
|
||||
import { Briefcase, Tag, Box, Layers } from 'lucide-react';
|
||||
import { ProjectResponse } from '@/types';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 };
|
||||
workspaceId: number;
|
||||
reviewId: number;
|
||||
}
|
||||
|
||||
const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Element> = {
|
||||
@ -16,17 +20,33 @@ 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,
|
||||
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 (
|
||||
<Link
|
||||
to={`/admin/${workspaceId}/reviews/${projectId}/${reviewId}`}
|
||||
className="block hover:bg-gray-100"
|
||||
>
|
||||
<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">
|
||||
<p className="text-sm font-semibold text-[#333238]">{title}</p>
|
||||
<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
|
||||
@ -43,5 +63,6 @@ export default function ReviewItem({ title, createdTime, creatorName, project, s
|
||||
<p className="text-xs text-[#737278]">Created at {createdTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
73
frontend/src/components/ReviewList/WorkspaceReviewList.tsx
Normal file
73
frontend/src/components/ReviewList/WorkspaceReviewList.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
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}
|
||||
workspaceId={workspaceId}
|
||||
reviewId={item.reviewId}
|
||||
title={item.title}
|
||||
createdTime={item.createAt}
|
||||
creatorName={item.nickname}
|
||||
projectId={item.projectId}
|
||||
status={item.status}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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}
|
||||
return projectId && Number(projectId) > 0 ? (
|
||||
<ProjectReviewList
|
||||
projectId={Number(projectId)}
|
||||
workspaceId={Number(workspaceId)}
|
||||
/>
|
||||
</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>
|
||||
) : (
|
||||
<WorkspaceReviewList workspaceId={Number(workspaceId)} />
|
||||
);
|
||||
}
|
||||
|
@ -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),
|
||||
});
|
||||
}
|
@ -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),
|
||||
});
|
||||
}
|
15
frontend/src/queries/workspaces/useWorkspaceReviewsQuery.tsx
Normal file
15
frontend/src/queries/workspaces/useWorkspaceReviewsQuery.tsx
Normal 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),
|
||||
});
|
||||
}
|
@ -13,6 +13,8 @@ import { Suspense } from 'react';
|
||||
import WorkspaceBrowseIndex from '@/pages/WorkspaceBrowseIndex';
|
||||
import AdminIndex from '@/pages/AdminIndex';
|
||||
import LabelCanvas from '@/pages/LabelCanvas';
|
||||
import ReviewDetail from '@/components/ReviewDetail';
|
||||
|
||||
export const webPath = {
|
||||
home: () => '/',
|
||||
browse: () => '/browse',
|
||||
@ -71,7 +73,7 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{
|
||||
path: `${webPath.admin()}/:workspaceId/project/:projectId?`,
|
||||
path: `${webPath.admin()}/:workspaceId`,
|
||||
element: (
|
||||
<Suspense fallback={<div></div>}>
|
||||
<AdminLayout />
|
||||
@ -86,6 +88,10 @@ const router = createBrowserRouter([
|
||||
path: 'reviews',
|
||||
element: <ReviewList />,
|
||||
},
|
||||
{
|
||||
path: 'reviews/:projectId/:reviewId',
|
||||
element: <ReviewDetail />,
|
||||
},
|
||||
{
|
||||
path: 'members',
|
||||
element: <AdminMemberManage />,
|
||||
|
@ -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;
|
||||
@ -159,6 +170,7 @@ export interface ReviewRequest {
|
||||
// 리뷰 응답 DTO
|
||||
export interface ReviewResponse {
|
||||
reviewId: number;
|
||||
projectId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
status: 'REQUESTED' | 'APPROVED' | 'REJECTED';
|
||||
@ -175,9 +187,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 +201,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 {
|
||||
|
Loading…
Reference in New Issue
Block a user