Merge branch 'fe/refactor/admin-page' into 'fe/develop'

Refactor: admin 페이지 부분 가완성

See merge request s11-s-project/S11P21S002!101
This commit is contained in:
홍창기 2024-09-20 08:39:32 +09:00
commit f440c1493c
23 changed files with 665 additions and 250 deletions

View File

@ -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",

View File

@ -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
View File

@ -0,0 +1,4 @@
declare module 'react-slick' {
const Slider: T;
export default Slider;
}

View 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);
}

View File

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

View File

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

View File

@ -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) => ({

View File

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

View File

@ -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) => {
addProjectMember.mutate({
projectId: Number(projectId),
memberId: memberId,
newMember: {
// Todo : 멤버 id로 수정하는 로직 수정해야한다.
// memberId: data.email,
memberId: 0,
privilegeType: data.role,
},
});
console.log('Invited:', data);
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: {
memberId: 0,
privilegeType: data.role,
},
});
}
setInviteModalOpen(false);
};
@ -39,7 +49,8 @@ export default function AdminMemberManage() {
<MemberAddModal onSubmit={handleMemberInvite} />
</header>
<AdminMemberManageForm members={members} />
{workspaceId && <WorkspaceMemberManageForm />}
{projectId && <ProjectMemberManageForm />}
</div>
);
}

View File

@ -3,11 +3,17 @@ import { cn } from '@/lib/utils';
export default function AdminMenuSidebar() {
const navigate = useNavigate();
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>();
const { workspaceId } = useParams<{ workspaceId: string }>();
const menuItems = [
{ label: '리뷰', path: `/admin/${workspaceId}${projectId ? `/project/${projectId}` : ''}/reviews` },
{ label: '멤버 관리', path: `/admin/${workspaceId}${projectId ? `/project/${projectId}` : ''}/members` },
{
label: '리뷰',
path: `/admin/${workspaceId}/reviews`,
},
{
label: '멤버 관리',
path: `/admin/${workspaceId}/members`,
},
];
return (

View File

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

View File

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

View File

@ -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}
<FormItem>
<FormLabel className="body-strong"></FormLabel>
<FormControl>
<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"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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>

View File

@ -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"
>
</button>
<button
onClick={handleDelete}
className="rounded-lg bg-red-500 px-4 py-2 text-white hover:bg-red-600"
>
</button>
<div className="actions mt-6 flex justify-end space-x-2">
{reviewDetail.reviewStatus !== 'APPROVED' && (
<Button
variant="default"
onClick={handleApprove}
>
</Button>
)}
{reviewDetail.reviewStatus !== 'REJECTED' && (
<Button
variant="destructive"
onClick={handleReject}
>
</Button>
)}
</div>
</div>
);

View 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>
);
}

View File

@ -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,32 +20,49 @@ 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 (
<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>
</div>
{type && (
<div
className="mt-1 inline-flex max-w-fit items-center gap-1 rounded-full px-3 py-1 text-xs text-white"
style={{ backgroundColor: type.color, padding: '1px 5px' }}
>
{icon}
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{type.text}</span>
<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]">{projectData?.title}</p>
</div>
)}
{type && (
<div
className="mt-1 inline-flex max-w-fit items-center gap-1 rounded-full px-3 py-1 text-xs text-white"
style={{ backgroundColor: type.color, padding: '1px 5px' }}
>
{icon}
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{type.text}</span>
</div>
)}
</div>
<div className="flex flex-col items-end gap-1">
<div className="rounded-full bg-[#cbe2f9] px-3 py-0.5 text-center text-xs text-[#0b5cad]">{status}</div>
<p className="text-xs text-[#737278]">Created at {createdTime}</p>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<div className="rounded-full bg-[#cbe2f9] px-3 py-0.5 text-center text-xs text-[#0b5cad]">{status}</div>
<p className="text-xs text-[#737278]">Created at {createdTime}</p>
</div>
</div>
</Link>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
import { getWorkspaceReviews } from '@/api/workspaceApi';
import { useSuspenseQuery } from '@tanstack/react-query';
export default function useWorkspaceReviewsQuery(
workspaceId: number,
memberId: number,
reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED',
lastReviewId?: number,
limitPage?: number
) {
return useSuspenseQuery({
queryKey: ['workspaceReviews', workspaceId, reviewStatus, lastReviewId],
queryFn: () => getWorkspaceReviews(workspaceId, memberId, reviewStatus, lastReviewId, limitPage),
});
}

View File

@ -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 />,

View File

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