Merge branch 'fe/feat/review' into 'fe/develop'
Feat: 리뷰 디테일 가안 구현 See merge request s11-s-project/S11P21S002!83
This commit is contained in:
commit
97816e30db
@ -1,5 +1,5 @@
|
|||||||
import api from '@/api/axiosConfig';
|
import api from '@/api/axiosConfig';
|
||||||
import { ProjectListResponse, ProjectResponse } from '@/types';
|
import { ProjectListResponse, ProjectResponse, ProjectMemberRequest, ProjectMemberResponse } from '@/types';
|
||||||
|
|
||||||
export async function getProjectList(
|
export async function getProjectList(
|
||||||
workspaceId: number,
|
workspaceId: number,
|
||||||
@ -46,32 +46,6 @@ export async function deleteProject(projectId: number, memberId: number) {
|
|||||||
.then(({ data }) => data);
|
.then(({ data }) => data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addProjectMember(
|
|
||||||
projectId: number,
|
|
||||||
memberId: number,
|
|
||||||
newMemberId: number,
|
|
||||||
privilegeType: string
|
|
||||||
) {
|
|
||||||
return api
|
|
||||||
.post(
|
|
||||||
`/projects/${projectId}/members`,
|
|
||||||
{ memberId: newMemberId, privilegeType },
|
|
||||||
{
|
|
||||||
params: { memberId },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(({ data }) => data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeProjectMember(projectId: number, memberId: number, targetMemberId: number) {
|
|
||||||
return api
|
|
||||||
.delete(`/projects/${projectId}/members`, {
|
|
||||||
params: { memberId },
|
|
||||||
data: { memberId: targetMemberId },
|
|
||||||
})
|
|
||||||
.then(({ data }) => data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createProject(
|
export async function createProject(
|
||||||
workspaceId: number,
|
workspaceId: number,
|
||||||
memberId: number,
|
memberId: number,
|
||||||
@ -83,3 +57,43 @@ export async function createProject(
|
|||||||
})
|
})
|
||||||
.then(({ data }) => data);
|
.then(({ data }) => data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 프로젝트 멤버 조회
|
||||||
|
export async function getProjectMembers(projectId: number, memberId: number) {
|
||||||
|
return api
|
||||||
|
.get<ProjectMemberResponse[]>(`/projects/${projectId}/members`, {
|
||||||
|
params: { memberId },
|
||||||
|
})
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 멤버 추가
|
||||||
|
export async function addProjectMember(projectId: number, memberId: number, newMember: ProjectMemberRequest) {
|
||||||
|
return api
|
||||||
|
.post<ProjectMemberResponse>(`/projects/${projectId}/members`, newMember, {
|
||||||
|
params: { memberId },
|
||||||
|
})
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 멤버 권한 수정
|
||||||
|
export async function updateProjectMemberPrivilege(
|
||||||
|
projectId: number,
|
||||||
|
memberId: number,
|
||||||
|
privilegeData: ProjectMemberRequest
|
||||||
|
) {
|
||||||
|
return api
|
||||||
|
.put<ProjectMemberResponse>(`/projects/${projectId}/members`, privilegeData, {
|
||||||
|
params: { memberId },
|
||||||
|
})
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 멤버 삭제
|
||||||
|
export async function removeProjectMember(projectId: number, memberId: number, targetMemberId: number) {
|
||||||
|
return api
|
||||||
|
.delete(`/projects/${projectId}/members`, {
|
||||||
|
params: { memberId, targetMemberId },
|
||||||
|
})
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
70
frontend/src/api/reviewApi.ts
Normal file
70
frontend/src/api/reviewApi.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import api from '@/api/axiosConfig';
|
||||||
|
import { ReviewDetailResponse, ReviewRequest, ReviewResponse } from '@/types';
|
||||||
|
|
||||||
|
// 리뷰 단건 조회
|
||||||
|
export async function getReviewDetail(projectId: number, reviewId: number, memberId: number) {
|
||||||
|
return api
|
||||||
|
.get<ReviewDetailResponse>(`/projects/${projectId}/reviews/${reviewId}`, {
|
||||||
|
params: { memberId },
|
||||||
|
})
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리뷰 생성
|
||||||
|
export async function createReview(projectId: number, memberId: number, reviewData: ReviewRequest) {
|
||||||
|
return api
|
||||||
|
.post<ReviewResponse>(`/projects/${projectId}/reviews`, reviewData, {
|
||||||
|
params: { memberId },
|
||||||
|
})
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리뷰 수정
|
||||||
|
export async function updateReview(projectId: number, reviewId: number, memberId: number, reviewData: ReviewRequest) {
|
||||||
|
return api
|
||||||
|
.put<ReviewResponse>(`/projects/${projectId}/reviews/${reviewId}`, reviewData, {
|
||||||
|
params: { memberId },
|
||||||
|
})
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리뷰 삭제
|
||||||
|
export async function deleteReview(projectId: number, reviewId: number, memberId: number) {
|
||||||
|
return api
|
||||||
|
.delete(`/projects/${projectId}/reviews/${reviewId}`, {
|
||||||
|
params: { memberId },
|
||||||
|
})
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리뷰 상태 변경
|
||||||
|
export async function updateReviewStatus(projectId: number, reviewId: number, memberId: number, reviewStatus: string) {
|
||||||
|
return api
|
||||||
|
.put<ReviewResponse>(
|
||||||
|
`/projects/${projectId}/reviews/${reviewId}/status`,
|
||||||
|
{ reviewStatus },
|
||||||
|
{
|
||||||
|
params: { memberId },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReviewByStatus(
|
||||||
|
projectId: number,
|
||||||
|
memberId: number,
|
||||||
|
reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED',
|
||||||
|
lastReviewId?: number,
|
||||||
|
limitPage: number = 10
|
||||||
|
) {
|
||||||
|
return api
|
||||||
|
.get<ReviewResponse[]>(`/projects/${projectId}/reviews`, {
|
||||||
|
params: {
|
||||||
|
memberId,
|
||||||
|
limitPage,
|
||||||
|
...(reviewStatus ? { reviewStatus } : {}),
|
||||||
|
...(lastReviewId ? { lastReviewId } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
@ -1,39 +0,0 @@
|
|||||||
import '@/index.css';
|
|
||||||
import { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import AdminLayout from './index';
|
|
||||||
import { Workspace } from '@/types';
|
|
||||||
|
|
||||||
const meta: Meta<typeof AdminLayout> = {
|
|
||||||
title: 'Layout/AdminLayout',
|
|
||||||
component: AdminLayout,
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof AdminLayout>;
|
|
||||||
|
|
||||||
const workspace: Workspace = {
|
|
||||||
id: 1,
|
|
||||||
name: 'Workspace Alpha',
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Project Alpha',
|
|
||||||
type: 'Segmentation',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Project Beta',
|
|
||||||
type: 'Classification',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
render: () => <AdminLayout workspace={workspace} />,
|
|
||||||
};
|
|
@ -1,72 +1,24 @@
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet, useParams } from 'react-router-dom';
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import Header from '../Header';
|
import Header from '../Header';
|
||||||
import { ResizablePanelGroup, ResizablePanel } from '../ui/resizable';
|
import { ResizablePanelGroup, ResizablePanel } from '../ui/resizable';
|
||||||
import AdminProjectSidebar from '../AdminProjectSidebar';
|
import AdminProjectSidebar from '../AdminProjectSidebar';
|
||||||
import AdminMenuSidebar from '../AdminMenuSidebar';
|
import AdminMenuSidebar from '../AdminMenuSidebar';
|
||||||
import { Workspace } from '@/types';
|
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
export default function AdminLayout() {
|
||||||
workspace?: Workspace;
|
const { projectId } = useParams<{ projectId?: string }>();
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminLayout({ workspace }: AdminLayoutProps) {
|
|
||||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
|
||||||
|
|
||||||
const numericWorkspaceId = workspaceId ? parseInt(workspaceId, 10) : 0;
|
|
||||||
|
|
||||||
const effectiveWorkspace: Workspace = workspace || {
|
|
||||||
id: numericWorkspaceId,
|
|
||||||
name: workspaceId ? `workspace-${workspaceId}` : 'default-workspace',
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'project1',
|
|
||||||
type: 'Detection',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'project2',
|
|
||||||
type: 'Detection',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'project3',
|
|
||||||
type: 'Detection',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'project4',
|
|
||||||
type: 'Detection',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'project5',
|
|
||||||
type: 'Detection',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header className="fixed left-0 top-0" />
|
<Header className="fixed left-0 top-0" />
|
||||||
<div className="mt-16 h-[calc(100vh-64px)] w-screen">
|
<div className="mt-16 h-[calc(100vh-64px)] w-screen">
|
||||||
<ResizablePanelGroup direction="horizontal">
|
<ResizablePanelGroup direction="horizontal">
|
||||||
<AdminProjectSidebar
|
<AdminProjectSidebar />
|
||||||
workspaceName={effectiveWorkspace.name}
|
|
||||||
projects={effectiveWorkspace.projects}
|
|
||||||
/>
|
|
||||||
<ResizablePanel className="flex w-full items-center">
|
<ResizablePanel className="flex w-full items-center">
|
||||||
<main className="h-full grow">
|
<main className="h-full grow">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<AdminMenuSidebar />
|
{projectId && <AdminMenuSidebar />}
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -1,25 +1,29 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form';
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '../ui/form';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Button } from '../ui/button';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
|
import { ProjectMemberResponse } from '@/types';
|
||||||
|
import { useUpdateProjectMemberPrivilege } from '@/hooks/useProjectHooks';
|
||||||
|
|
||||||
type Role = 'admin' | 'editor' | 'viewer';
|
type Role = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER';
|
||||||
|
|
||||||
const roles: Role[] = ['admin', 'editor', 'viewer'];
|
const roles: Role[] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER'];
|
||||||
|
|
||||||
const roleToStr: { [key in Role]: string } = {
|
const roleToStr: { [key in Role]: string } = {
|
||||||
admin: '관리자',
|
ADMIN: '관리자',
|
||||||
editor: '에디터',
|
MANAGER: '매니저',
|
||||||
viewer: '뷰어',
|
EDITOR: '에디터',
|
||||||
|
VIEWER: '뷰어',
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
members: z.array(
|
members: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email({ message: '올바른 이메일 형식을 입력해주세요.' }),
|
memberId: z.number(),
|
||||||
|
nickname: z.string().nonempty('닉네임을 입력하세요.'),
|
||||||
role: z.enum(roles as [Role, ...Role[]], { errorMap: () => ({ message: '역할을 선택해주세요.' }) }),
|
role: z.enum(roles as [Role, ...Role[]], { errorMap: () => ({ message: '역할을 선택해주세요.' }) }),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
@ -27,63 +31,52 @@ const formSchema = z.object({
|
|||||||
|
|
||||||
export type MemberManageFormValues = z.infer<typeof formSchema>;
|
export type MemberManageFormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
interface Member {
|
|
||||||
email: string;
|
|
||||||
role: Role;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminMemberManageFormProps {
|
interface AdminMemberManageFormProps {
|
||||||
members: Member[];
|
members: ProjectMemberResponse[];
|
||||||
onSubmit: (data: MemberManageFormValues) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminMemberManageForm({ members, onSubmit }: AdminMemberManageFormProps) {
|
export default function AdminMemberManageForm({ members }: AdminMemberManageFormProps) {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
|
const { mutate: updatePrivilege } = useUpdateProjectMemberPrivilege();
|
||||||
|
|
||||||
const form = useForm<MemberManageFormValues>({
|
const form = useForm<MemberManageFormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: { members },
|
defaultValues: {
|
||||||
|
members: members.map((m) => ({
|
||||||
|
memberId: m.memberId,
|
||||||
|
nickname: m.nickname,
|
||||||
|
role: m.privilegeType as Role,
|
||||||
|
})),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedMembers = members.reduce<{ [key: string]: { email: string; role: Role }[] }>((acc, member) => {
|
const handleRoleChange = (memberId: number, role: Role) => {
|
||||||
if (!acc[member.role]) acc[member.role] = [];
|
updatePrivilege({
|
||||||
acc[member.role].push(member);
|
projectId: Number(projectId),
|
||||||
return acc;
|
memberId,
|
||||||
}, {});
|
privilegeData: {
|
||||||
|
memberId,
|
||||||
const roleOrder: Role[] = ['admin', 'editor', 'viewer'];
|
privilegeType: role,
|
||||||
|
},
|
||||||
const sortedGroupedMembers = Object.entries(groupedMembers).sort(
|
});
|
||||||
([roleA], [roleB]) => roleOrder.indexOf(roleA as Role) - roleOrder.indexOf(roleB as Role)
|
};
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
{sortedGroupedMembers.map(([role, groupMembers]) => {
|
{members.map((member, index) => (
|
||||||
if (!groupMembers || groupMembers.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={role}
|
key={member.memberId}
|
||||||
className="flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<FormLabel className="text-sm font-semibold text-[#333238]">{roleToStr[role as Role]}</FormLabel>
|
|
||||||
{groupMembers.map((member, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-center gap-4"
|
className="flex items-center gap-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
name={`members.${members.findIndex((m) => m.email === member.email)}.email`}
|
name={`members.${index}.nickname`}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="email@example.com"
|
placeholder="닉네임을 입력하세요."
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -91,14 +84,18 @@ export default function AdminMemberManageForm({ members, onSubmit }: AdminMember
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name={`members.${members.findIndex((m) => m.email === member.email)}.role`}
|
name={`members.${index}.role`}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
handleRoleChange(member.memberId, value as Role);
|
||||||
|
}}
|
||||||
defaultValue={field.value}
|
defaultValue={field.value}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@ -123,17 +120,6 @@ export default function AdminMemberManageForm({ members, onSubmit }: AdminMember
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outlinePrimary"
|
|
||||||
disabled={!form.formState.isValid}
|
|
||||||
>
|
|
||||||
역할 설정
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
import { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import AdminMemberManage from '.';
|
|
||||||
import { MemberManageFormValues } from './AdminMemberManageForm';
|
|
||||||
|
|
||||||
const meta: Meta<typeof AdminMemberManage> = {
|
|
||||||
title: 'Components/AdminMemberManage',
|
|
||||||
component: AdminMemberManage,
|
|
||||||
argTypes: {
|
|
||||||
title: { control: 'text' },
|
|
||||||
members: { control: 'object' },
|
|
||||||
projects: { control: 'object' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof AdminMemberManage>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
title: '프로젝트 멤버 관리하기',
|
|
||||||
members: [
|
|
||||||
{ email: 'admin1@example.com', role: 'admin' },
|
|
||||||
{ email: 'admin2@example.com', role: 'admin' },
|
|
||||||
{ email: 'viewer3@example.com', role: 'viewer' },
|
|
||||||
{ email: 'editor1@example.com', role: 'editor' },
|
|
||||||
{ email: 'editor2@example.com', role: 'editor' },
|
|
||||||
{ email: 'editor3@example.com', role: 'editor' },
|
|
||||||
{ email: 'editor4@example.com', role: 'editor' },
|
|
||||||
],
|
|
||||||
projects: [
|
|
||||||
{ id: 'project-1', name: '프로젝트 A' },
|
|
||||||
{ id: 'project-2', name: '프로젝트 B' },
|
|
||||||
{ id: 'project-3', name: '프로젝트 C' },
|
|
||||||
],
|
|
||||||
onProjectChange: (projectId: string) => console.log('Selected Project:', projectId),
|
|
||||||
onMemberInvite: () => console.log('Invite member'),
|
|
||||||
onSubmit: (data: MemberManageFormValues) => console.log('Submitted:', data),
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,70 +1,45 @@
|
|||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
import { useState } from 'react';
|
||||||
import AdminMemberManageForm, { MemberManageFormValues } from './AdminMemberManageForm';
|
import AdminMemberManageForm from './AdminMemberManageForm';
|
||||||
import { Button } from '@/components/ui/button';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import useProjectMembersQuery from '@/queries/useProjectMembersQuery';
|
||||||
|
import useAuthStore from '@/stores/useAuthStore';
|
||||||
|
import { useAddProjectMember } from '@/hooks/useProjectHooks';
|
||||||
|
import MemberAddModal from '../MemberAddModal';
|
||||||
|
import { MemberAddFormValues } from '../MemberAddModal/MemberAddForm';
|
||||||
|
|
||||||
type Role = 'admin' | 'editor' | 'viewer';
|
export default function AdminMemberManage() {
|
||||||
|
const { projectId } = useParams<{ workspaceId: string; projectId: string }>();
|
||||||
|
const profile = useAuthStore((state) => state.profile);
|
||||||
|
const memberId = profile?.id || 0;
|
||||||
|
|
||||||
interface Member {
|
const { data: members = [] } = useProjectMembersQuery(Number(projectId), memberId);
|
||||||
email: string;
|
const addProjectMember = useAddProjectMember();
|
||||||
role: Role;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
const [, setInviteModalOpen] = useState(false);
|
||||||
id: string;
|
|
||||||
name: string;
|
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);
|
||||||
|
setInviteModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminMemberManage({
|
|
||||||
title = '멤버 관리',
|
|
||||||
projects = [
|
|
||||||
{ id: 'project-1', name: '프로젝트 A' },
|
|
||||||
{ id: 'project-2', name: '프로젝트 B' },
|
|
||||||
],
|
|
||||||
onProjectChange = (projectId: string) => console.log('Selected Project:', projectId),
|
|
||||||
onSubmit = (data: MemberManageFormValues) => console.log('Submitted:', data),
|
|
||||||
members = [
|
|
||||||
{ email: 'admin1@example.com', role: 'admin' },
|
|
||||||
{ email: 'viewer2@example.com', role: 'viewer' },
|
|
||||||
],
|
|
||||||
onMemberInvite = () => console.log('Invite member'),
|
|
||||||
}: {
|
|
||||||
title?: string;
|
|
||||||
projects?: Project[];
|
|
||||||
onProjectChange?: (projectId: string) => void;
|
|
||||||
onSubmit?: (data: MemberManageFormValues) => void;
|
|
||||||
members?: Member[];
|
|
||||||
onMemberInvite?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-6 border-b-[0.67px] border-[#dcdcde] bg-[#fbfafd] p-6">
|
<div className="flex w-full flex-col gap-6 border-b-[0.67px] border-[#dcdcde] bg-[#fbfafd] p-6">
|
||||||
<header className="flex w-full items-center gap-4">
|
<header className="flex w-full items-center gap-4">
|
||||||
<h1 className="flex-1 text-lg font-semibold text-[#333238]">{title}</h1>
|
<h1 className="flex-1 text-lg font-semibold text-[#333238]">멤버 관리</h1>
|
||||||
<Button
|
<MemberAddModal onSubmit={handleMemberInvite} />
|
||||||
variant="outlinePrimary"
|
|
||||||
onClick={onMemberInvite}
|
|
||||||
>
|
|
||||||
멤버 초대하기
|
|
||||||
</Button>
|
|
||||||
<Select onValueChange={onProjectChange}>
|
|
||||||
<SelectTrigger className="w-[200px]">
|
|
||||||
<SelectValue placeholder="프로젝트 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<SelectItem
|
|
||||||
key={project.id}
|
|
||||||
value={project.id}
|
|
||||||
>
|
|
||||||
{project.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</header>
|
</header>
|
||||||
<AdminMemberManageForm
|
|
||||||
onSubmit={onSubmit}
|
<AdminMemberManageForm members={members} />
|
||||||
members={members}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,11 +3,11 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
export default function AdminMenuSidebar() {
|
export default function AdminMenuSidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ label: '리뷰', path: `/admin/${id}/review` },
|
{ label: '리뷰', path: `/admin/${workspaceId}${projectId ? `/project/${projectId}` : ''}/reviews` },
|
||||||
{ label: '멤버 관리', path: `/admin/${id}/members` },
|
{ label: '멤버 관리', path: `/admin/${workspaceId}${projectId ? `/project/${projectId}` : ''}/members` },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,16 +1,31 @@
|
|||||||
import { ResizablePanel, ResizableHandle } from '../ui/resizable';
|
import { ResizablePanel, ResizableHandle } from '../ui/resizable';
|
||||||
import { Project } from '@/types';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { SquarePen } from 'lucide-react';
|
import { SquarePen } from 'lucide-react';
|
||||||
import { Button } from '../ui/button';
|
import useProjectListQuery from '@/queries/useProjectListQuery';
|
||||||
|
import { useCreateProject } from '@/hooks/useProjectHooks';
|
||||||
|
import { ProjectRequest } from '@/types';
|
||||||
|
import useAuthStore from '@/stores/useAuthStore';
|
||||||
|
import ProjectCreateModal from '../ProjectCreateModal';
|
||||||
|
|
||||||
interface AdminProjectSidebarProps {
|
export default function AdminProjectSidebar(): JSX.Element {
|
||||||
workspaceName: string;
|
|
||||||
projects: Project[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminProjectSidebar({ workspaceName, projects }: AdminProjectSidebarProps): JSX.Element {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||||
|
const profile = useAuthStore((state) => state.profile);
|
||||||
|
const memberId = profile?.id || 0;
|
||||||
|
|
||||||
|
const { data: projectsResponse } = useProjectListQuery(Number(workspaceId), memberId);
|
||||||
|
|
||||||
|
const projects = projectsResponse?.workspaceResponses ?? [];
|
||||||
|
|
||||||
|
const createProject = useCreateProject();
|
||||||
|
|
||||||
|
const handleCreateProject = (data: ProjectRequest) => {
|
||||||
|
createProject.mutate({
|
||||||
|
workspaceId: Number(workspaceId),
|
||||||
|
memberId,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -22,28 +37,24 @@ export default function AdminProjectSidebar({ workspaceName, projects }: AdminPr
|
|||||||
>
|
>
|
||||||
<header className="flex w-full items-center justify-between gap-2 border-b border-gray-200 p-4">
|
<header className="flex w-full items-center justify-between gap-2 border-b border-gray-200 p-4">
|
||||||
<h1 className="heading w-full overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900">
|
<h1 className="heading w-full overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900">
|
||||||
{workspaceName}
|
{workspaceId}
|
||||||
</h1>
|
</h1>
|
||||||
<button className="p-2">
|
<button className="p-2">
|
||||||
<SquarePen size={16} />
|
<SquarePen size={16} />
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<ProjectCreateModal
|
||||||
variant="outline"
|
buttonClass="caption border-gray-800 bg-gray-100"
|
||||||
size="xs"
|
onSubmit={handleCreateProject}
|
||||||
className="caption border-gray-800 bg-gray-100"
|
/>
|
||||||
onClick={() => console.log('New project')}
|
|
||||||
>
|
|
||||||
새 프로젝트
|
|
||||||
</Button>
|
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-col gap-2 p-4">
|
<div className="flex flex-col gap-2 p-4">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className="body cursor-pointer rounded-md px-3 py-2 text-left hover:bg-gray-200"
|
className="body cursor-pointer rounded-md px-3 py-2 text-left hover:bg-gray-200"
|
||||||
onClick={() => navigate(`/project/${project.id}`)}
|
onClick={() => navigate(`/admin/${workspaceId}/project/${project.id}`)}
|
||||||
>
|
>
|
||||||
{project.name}
|
{project.title}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,7 +7,7 @@ export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
|||||||
|
|
||||||
export default function Header({ className, ...props }: HeaderProps) {
|
export default function Header({ className, ...props }: HeaderProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>();
|
||||||
const isWorkspaceIdNaN = isNaN(Number(workspaceId));
|
const isWorkspaceIdNaN = isNaN(Number(workspaceId));
|
||||||
|
|
||||||
const isHomePage = location.pathname === '/';
|
const isHomePage = location.pathname === '/';
|
||||||
@ -46,7 +46,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
|||||||
labeling
|
labeling
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={`/admin/${workspaceId}`}
|
to={`/admin/${workspaceId}/project/${projectId ?? ''}`}
|
||||||
className={cn('text-color-text-default-default', 'font-body', 'text-sm sm:text-base md:text-lg')}
|
className={cn('text-color-text-default-default', 'font-body', 'text-sm sm:text-base md:text-lg')}
|
||||||
>
|
>
|
||||||
admin
|
admin
|
||||||
|
@ -6,14 +6,15 @@ import { Input } from '../ui/input';
|
|||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
|
|
||||||
type Role = 'admin' | 'editor' | 'viewer';
|
type PrivilegeType = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER';
|
||||||
|
|
||||||
const roles: Role[] = ['admin', 'editor', 'viewer'];
|
const privilegeTypes: readonly ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER'] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER'];
|
||||||
|
|
||||||
const roleToStr: { [key in Role]: string } = {
|
const privilegeTypeToStr: { [key in PrivilegeType]: string } = {
|
||||||
admin: '관리자',
|
ADMIN: '관리자',
|
||||||
editor: '에디터',
|
MANAGER: '매니저',
|
||||||
viewer: '뷰어',
|
EDITOR: '에디터',
|
||||||
|
VIEWER: '뷰어',
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@ -26,7 +27,7 @@ const formSchema = z.object({
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: '초대할 멤버의 이메일 주소를 입력해주세요.',
|
message: '초대할 멤버의 이메일 주소를 입력해주세요.',
|
||||||
}),
|
}),
|
||||||
role: z.enum(['admin', 'editor', 'viewer']),
|
role: z.enum(privilegeTypes),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type MemberAddFormValues = z.infer<typeof formSchema>;
|
export type MemberAddFormValues = z.infer<typeof formSchema>;
|
||||||
@ -80,12 +81,12 @@ export default function MemberAddForm({ onSubmit }: { onSubmit: (data: MemberAdd
|
|||||||
<SelectValue placeholder="초대할 멤버의 역할을 선택해주세요." />
|
<SelectValue placeholder="초대할 멤버의 역할을 선택해주세요." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{roles.map((role) => (
|
{privilegeTypes.map((role) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={role}
|
key={role}
|
||||||
value={role}
|
value={role}
|
||||||
>
|
>
|
||||||
{roleToStr[role]}
|
{privilegeTypeToStr[role]}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
import '@/index.css';
|
|
||||||
import MemberAddModal from '.';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'Modal/MemberAddModal',
|
|
||||||
component: MemberAddModal,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Default = () => (
|
|
||||||
<MemberAddModal
|
|
||||||
title="프로젝트 멤버 초대하기"
|
|
||||||
onClose={() => {
|
|
||||||
console.log('close');
|
|
||||||
}}
|
|
||||||
onSubmit={(data) => {
|
|
||||||
console.log(data);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
@ -1,27 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
import MemberAddForm, { MemberAddFormValues } from './MemberAddForm';
|
import MemberAddForm, { MemberAddFormValues } from './MemberAddForm';
|
||||||
import XIcon from '@/assets/icons/x.svg?react';
|
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
export default function MemberAddModal({
|
interface MemberAddModalProps {
|
||||||
title = '새 멤버 초대',
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
title?: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (data: MemberAddFormValues) => void;
|
onSubmit: (data: MemberAddFormValues) => void;
|
||||||
}) {
|
buttonClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MemberAddModal({ onSubmit, buttonClass = '' }: MemberAddModalProps) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const handleOpen = () => setIsOpen(true);
|
||||||
|
const handleClose = () => setIsOpen(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-[610px] flex-col gap-10 rounded-3xl border px-10 py-5 shadow-lg">
|
<Dialog
|
||||||
<header className="flex gap-5">
|
open={isOpen}
|
||||||
<h1 className="small-title w-full">{title}</h1>
|
onOpenChange={setIsOpen}
|
||||||
<button
|
|
||||||
className="flex h-8 w-8 items-center justify-center"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
>
|
||||||
<XIcon className="stroke-gray-900" />
|
<DialogTrigger asChild>
|
||||||
</button>
|
<Button
|
||||||
</header>
|
variant="outlinePrimary"
|
||||||
<MemberAddForm onSubmit={onSubmit} />
|
className={`${buttonClass}`}
|
||||||
</div>
|
onClick={handleOpen}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>멤버 초대하기</span>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader title="새 멤버 초대" />
|
||||||
|
<MemberAddForm
|
||||||
|
onSubmit={(data: MemberAddFormValues) => {
|
||||||
|
onSubmit(data);
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
86
frontend/src/components/ReviewDetail/index.tsx
Normal file
86
frontend/src/components/ReviewDetail/index.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import useReviewDetailQuery from '@/queries/useReviewDetailQuery';
|
||||||
|
import { useUpdateReview, useDeleteReview } from '@/hooks/useReviewHooks';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function ReviewDetail() {
|
||||||
|
const { projectId, reviewId } = useParams<{ projectId: string; reviewId: string }>();
|
||||||
|
const memberId = 1;
|
||||||
|
|
||||||
|
const { data: reviewDetail } = useReviewDetailQuery(Number(projectId), Number(reviewId), memberId);
|
||||||
|
const updateReview = useUpdateReview();
|
||||||
|
const deleteReview = useDeleteReview();
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
updateReview.mutate({
|
||||||
|
projectId: Number(projectId),
|
||||||
|
reviewId: Number(reviewId),
|
||||||
|
memberId,
|
||||||
|
reviewData: {
|
||||||
|
title: reviewDetail.title,
|
||||||
|
content: reviewDetail.content,
|
||||||
|
imageIds: reviewDetail.images.map((image) => image.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteReview.mutate({
|
||||||
|
projectId: Number(projectId),
|
||||||
|
reviewId: Number(reviewId),
|
||||||
|
memberId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!reviewDetail) return <p>Loading...</p>;
|
||||||
|
|
||||||
|
const { title, content, reviewStatus, images } = reviewDetail;
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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]"
|
||||||
|
>
|
||||||
|
{image.imageTitle} (status: {image.status})
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,31 +1,23 @@
|
|||||||
import { Briefcase, Tag, Box, Layers, Pen } from 'lucide-react';
|
import { Briefcase, Tag, Box, Layers } from 'lucide-react';
|
||||||
|
import { ProjectResponse } from '@/types';
|
||||||
|
|
||||||
interface ReviewItemProps {
|
interface ReviewItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
createdTime: string;
|
createdTime: string;
|
||||||
creatorName: string;
|
creatorName: string;
|
||||||
project: string;
|
project: ProjectResponse;
|
||||||
status: string;
|
status: string;
|
||||||
type: { text: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; color: string };
|
type: { text: 'classification' | 'detection' | 'segmentation'; color: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeIcons: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', JSX.Element> = {
|
const typeIcons: Record<'classification' | 'detection' | 'segmentation', JSX.Element> = {
|
||||||
Classification: <Tag className="h-4 w-4 text-white" />,
|
classification: <Tag className="h-4 w-4 text-white" />,
|
||||||
Detection: <Box className="h-4 w-4 text-white" />,
|
detection: <Box className="h-4 w-4 text-white" />,
|
||||||
Polygon: <Layers className="h-4 w-4 text-white" />,
|
segmentation: <Layers className="h-4 w-4 text-white" />,
|
||||||
Polyline: <Pen className="h-4 w-4 text-white" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeStyles: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', string> = {
|
|
||||||
Classification: '#a2eeef',
|
|
||||||
Detection: '#d4c5f9',
|
|
||||||
Polygon: '#f9c5d4',
|
|
||||||
Polyline: '#c5f9d4',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReviewItem({ title, createdTime, creatorName, project, status, type }: ReviewItemProps) {
|
export default function ReviewItem({ title, createdTime, creatorName, project, status, type }: ReviewItemProps) {
|
||||||
const icon = typeIcons[type.text];
|
const icon = typeIcons[project.projectType];
|
||||||
const bgColor = typeStyles[type.text];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[100px] w-full items-center justify-between border-b-[0.67px] border-[#ececef] bg-[#fbfafd] p-4">
|
<div className="flex h-[100px] w-full items-center justify-between border-b-[0.67px] border-[#ececef] bg-[#fbfafd] p-4">
|
||||||
@ -34,12 +26,12 @@ export default function ReviewItem({ title, createdTime, creatorName, project, s
|
|||||||
<p className="mt-1 text-xs text-[#737278]">by {creatorName}</p>
|
<p className="mt-1 text-xs text-[#737278]">by {creatorName}</p>
|
||||||
<div className="mt-1 flex items-center">
|
<div className="mt-1 flex items-center">
|
||||||
<Briefcase className="h-3 w-3 text-[#737278]" />
|
<Briefcase className="h-3 w-3 text-[#737278]" />
|
||||||
<p className="ml-1 text-xs text-[#737278]">{project}</p>
|
<p className="ml-1 text-xs text-[#737278]">{project.title}</p>
|
||||||
</div>
|
</div>
|
||||||
{type && (
|
{type && (
|
||||||
<div
|
<div
|
||||||
className="mt-1 inline-flex max-w-fit items-center gap-1 rounded-full px-3 py-1 text-xs text-white"
|
className="mt-1 inline-flex max-w-fit items-center gap-1 rounded-full px-3 py-1 text-xs text-white"
|
||||||
style={{ backgroundColor: bgColor, padding: '1px 5px' }}
|
style={{ backgroundColor: type.color, padding: '1px 5px' }}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{type.text}</span>
|
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{type.text}</span>
|
||||||
|
@ -5,8 +5,6 @@ import { cn } from '@/lib/utils';
|
|||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: 'latest', label: '최신 순' },
|
{ value: 'latest', label: '최신 순' },
|
||||||
{ value: 'oldest', label: '오래된 순' },
|
{ value: 'oldest', label: '오래된 순' },
|
||||||
{ value: 'comments', label: '댓글 많은 순' },
|
|
||||||
{ value: 'updates', label: '업데이트 많은 순' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ReviewSearchInputProps {
|
interface ReviewSearchInputProps {
|
||||||
|
@ -1,152 +1,66 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ReviewItem from './ReviewItem';
|
import ReviewItem from './ReviewItem';
|
||||||
import ReviewSearchInput from './ReviewSearchInput';
|
import ReviewSearchInput from './ReviewSearchInput';
|
||||||
|
import useReviewByStatusQuery from '@/queries/useReviewByStatusQuery';
|
||||||
|
import useProjectQuery from '@/queries/useProjectQuery';
|
||||||
|
import useAuthStore from '@/stores/useAuthStore';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
interface ReviewListProps {
|
export default function ReviewList(): JSX.Element {
|
||||||
acceptedCount?: number;
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
rejectedCount?: number;
|
const profile = useAuthStore((state) => state.profile);
|
||||||
pendingCount?: number;
|
const memberId = profile?.id || 0;
|
||||||
totalCount?: number;
|
|
||||||
items?: {
|
|
||||||
title: string;
|
|
||||||
createdTime: string;
|
|
||||||
creatorName: string;
|
|
||||||
project: string;
|
|
||||||
type: 'Classification' | 'Detection' | 'Polygon' | 'Polyline';
|
|
||||||
status: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeColors: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', string> = {
|
const [activeTab, setActiveTab] = useState<'REQUESTED' | 'APPROVED' | 'REJECTED' | 'all'>('REQUESTED');
|
||||||
Classification: '#a2eeef',
|
const [, setSearchQuery] = useState('');
|
||||||
Detection: '#d4c5f9',
|
|
||||||
Polygon: '#f9c5d4',
|
|
||||||
Polyline: '#c5f9d4',
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultItems: ReviewListProps['items'] = [
|
|
||||||
{
|
|
||||||
title: '리뷰 항목 1',
|
|
||||||
createdTime: '2024-09-09T10:00:00Z',
|
|
||||||
creatorName: '사용자 1',
|
|
||||||
project: '프로젝트 A',
|
|
||||||
type: 'Classification',
|
|
||||||
status: 'needs_review',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '리뷰 항목 2',
|
|
||||||
createdTime: '2024-09-08T14:30:00Z',
|
|
||||||
creatorName: '사용자 2',
|
|
||||||
project: '프로젝트 B',
|
|
||||||
type: 'Detection',
|
|
||||||
status: 'completed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '리뷰 항목 3',
|
|
||||||
createdTime: '2024-09-07T08:45:00Z',
|
|
||||||
creatorName: '사용자 3',
|
|
||||||
project: '프로젝트 C',
|
|
||||||
type: 'Polygon',
|
|
||||||
status: 'in_progress',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '리뷰 항목 4',
|
|
||||||
createdTime: '2024-09-06T10:20:00Z',
|
|
||||||
creatorName: '사용자 4',
|
|
||||||
project: '프로젝트 D',
|
|
||||||
type: 'Polyline',
|
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ReviewList({
|
|
||||||
acceptedCount = 1,
|
|
||||||
rejectedCount = 1,
|
|
||||||
pendingCount = 1,
|
|
||||||
totalCount = 3,
|
|
||||||
items = defaultItems,
|
|
||||||
}: ReviewListProps): JSX.Element {
|
|
||||||
const [activeTab, setActiveTab] = useState('pending');
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [sortValue, setSortValue] = useState('latest');
|
const [sortValue, setSortValue] = useState('latest');
|
||||||
|
|
||||||
const filteredItems = (items ?? [])
|
const { data: project } = useProjectQuery(Number(projectId), memberId);
|
||||||
.filter((item) => {
|
|
||||||
if (activeTab === 'pending') return item.status.toLowerCase() === 'needs_review';
|
const { data: reviews = [] } = useReviewByStatusQuery(
|
||||||
if (activeTab === 'accepted') return item.status.toLowerCase() === 'completed';
|
Number(projectId),
|
||||||
if (activeTab === 'rejected')
|
memberId,
|
||||||
return item.status.toLowerCase() === 'in_progress' || item.status.toLowerCase() === 'pending';
|
activeTab !== 'all' ? activeTab : undefined
|
||||||
if (activeTab === 'all') return true;
|
);
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.filter((item) => item.title.includes(searchQuery))
|
|
||||||
.sort((a, b) => {
|
|
||||||
switch (sortValue) {
|
|
||||||
case 'oldest':
|
|
||||||
return new Date(a.createdTime).getTime() - new Date(b.createdTime).getTime();
|
|
||||||
default:
|
|
||||||
return new Date(b.createdTime).getTime() - new Date(a.createdTime).getTime();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<div className="relative w-full px-4">
|
<div className="relative w-full px-4">
|
||||||
<div className="flex w-full items-center border-b-[0.67px] border-solid border-[#dcdcde]">
|
<div className="flex w-full items-center border-b-[0.67px] border-solid border-[#dcdcde]">
|
||||||
<button
|
<button
|
||||||
className={`flex h-12 w-[100px] items-center justify-between px-3 ${
|
className={`flex h-12 w-[100px] items-center justify-between px-3 ${activeTab === 'REQUESTED' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''}`}
|
||||||
activeTab === 'pending' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
|
onClick={() => setActiveTab('REQUESTED')}
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab('pending')}
|
|
||||||
>
|
>
|
||||||
<span className={`text-sm ${activeTab === 'pending' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
|
<span className={`text-sm ${activeTab === 'REQUESTED' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
|
||||||
요청
|
요청
|
||||||
</span>
|
</span>
|
||||||
<span className="flex h-4 w-6 items-center justify-center rounded-[160px] bg-[#ececef] text-xs text-[#626168]">
|
|
||||||
{pendingCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`flex h-12 w-[100px] items-center justify-between px-3 ${
|
className={`flex h-12 w-[100px] items-center justify-between px-3 ${activeTab === 'APPROVED' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''}`}
|
||||||
activeTab === 'accepted' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
|
onClick={() => setActiveTab('APPROVED')}
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab('accepted')}
|
|
||||||
>
|
>
|
||||||
<span className={`text-sm ${activeTab === 'accepted' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
|
<span className={`text-sm ${activeTab === 'APPROVED' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
|
||||||
승인
|
승인
|
||||||
</span>
|
</span>
|
||||||
<span className="flex h-4 w-6 items-center justify-center rounded-[160px] bg-[#ececef] text-xs text-[#626168]">
|
|
||||||
{acceptedCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`flex h-12 w-[100px] items-center justify-between px-3 ${
|
className={`flex h-12 w-[100px] items-center justify-between px-3 ${activeTab === 'REJECTED' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''}`}
|
||||||
activeTab === 'rejected' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
|
onClick={() => setActiveTab('REJECTED')}
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab('rejected')}
|
|
||||||
>
|
>
|
||||||
<span className={`text-sm ${activeTab === 'rejected' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
|
<span className={`text-sm ${activeTab === 'REJECTED' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
|
||||||
거부
|
거부
|
||||||
</span>
|
</span>
|
||||||
<span className="flex h-4 w-6 items-center justify-center rounded-[160px] bg-[#ececef] text-xs text-[#626168]">
|
|
||||||
{rejectedCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`flex h-12 w-[100px] items-center justify-between px-3 ${
|
className={`flex h-12 w-[100px] items-center justify-between px-3 ${activeTab === 'all' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''}`}
|
||||||
activeTab === 'all' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab('all')}
|
onClick={() => setActiveTab('all')}
|
||||||
>
|
>
|
||||||
<span className={`text-sm ${activeTab === 'all' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
|
<span className={`text-sm ${activeTab === 'all' ? 'font-semibold' : 'font-normal'} text-[#333238]`}>
|
||||||
전체
|
전체
|
||||||
</span>
|
</span>
|
||||||
<span className="flex h-4 w-6 items-center justify-center rounded-[160px] bg-[#ececef] text-xs text-[#626168]">
|
|
||||||
{totalCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -160,15 +74,23 @@ export default function ReviewList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full overflow-y-auto px-4">
|
<div className="relative w-full overflow-y-auto px-4">
|
||||||
{filteredItems.map((item, index) => (
|
{reviews.map((item) => (
|
||||||
<ReviewItem
|
<ReviewItem
|
||||||
key={index}
|
key={item.reviewId}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
createdTime={item.createdTime}
|
createdTime={item.createAt}
|
||||||
creatorName={item.creatorName}
|
creatorName={item.nickname}
|
||||||
project={item.project}
|
project={project}
|
||||||
status={item.status}
|
status={item.status}
|
||||||
type={{ text: item.type, color: typeColors[item.type] }}
|
type={{
|
||||||
|
text: project.projectType,
|
||||||
|
color:
|
||||||
|
project.projectType === 'classification'
|
||||||
|
? '#a2eeef'
|
||||||
|
: project.projectType === 'detection'
|
||||||
|
? '#d4c5f9'
|
||||||
|
: '#f9c5d4',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -113,8 +113,15 @@
|
|||||||
// });
|
// });
|
||||||
// };
|
// };
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { createProject, updateProject, deleteProject, addProjectMember, removeProjectMember } from '@/api/projectApi';
|
import {
|
||||||
import { ProjectResponse, ProjectRequest, ProjectMemberRequest } from '@/types';
|
createProject,
|
||||||
|
updateProject,
|
||||||
|
deleteProject,
|
||||||
|
addProjectMember,
|
||||||
|
updateProjectMemberPrivilege,
|
||||||
|
removeProjectMember,
|
||||||
|
} from '@/api/projectApi';
|
||||||
|
import { ProjectResponse, ProjectRequest, ProjectMemberRequest, ProjectMemberResponse } from '@/types';
|
||||||
|
|
||||||
export const useCreateProject = () => {
|
export const useCreateProject = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -151,23 +158,41 @@ export const useDeleteProject = () => {
|
|||||||
|
|
||||||
export const useAddProjectMember = () => {
|
export const useAddProjectMember = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<
|
||||||
return useMutation<void, Error, { projectId: number; memberId: number; data: ProjectMemberRequest }>({
|
ProjectMemberResponse,
|
||||||
mutationFn: ({ projectId, memberId, data }) =>
|
Error,
|
||||||
addProjectMember(projectId, memberId, data.memberId, data.privilegeType),
|
{ projectId: number; memberId: number; newMember: ProjectMemberRequest }
|
||||||
onSuccess: (_, variables) => {
|
>({
|
||||||
queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] });
|
mutationFn: ({ projectId, memberId, newMember }) => addProjectMember(projectId, memberId, newMember),
|
||||||
|
onSuccess: (_, { projectId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 프로젝트 멤버 권한 수정 훅
|
||||||
|
export const useUpdateProjectMemberPrivilege = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<
|
||||||
|
ProjectMemberResponse,
|
||||||
|
Error,
|
||||||
|
{ projectId: number; memberId: number; privilegeData: ProjectMemberRequest }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ projectId, memberId, privilegeData }) =>
|
||||||
|
updateProjectMemberPrivilege(projectId, memberId, privilegeData),
|
||||||
|
onSuccess: (_, { projectId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 프로젝트 멤버 삭제 훅
|
||||||
export const useRemoveProjectMember = () => {
|
export const useRemoveProjectMember = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<void, Error, { projectId: number; memberId: number; targetMemberId: number }>({
|
return useMutation<void, Error, { projectId: number; memberId: number; targetMemberId: number }>({
|
||||||
mutationFn: ({ projectId, memberId, targetMemberId }) => removeProjectMember(projectId, memberId, targetMemberId),
|
mutationFn: ({ projectId, memberId, targetMemberId }) => removeProjectMember(projectId, memberId, targetMemberId),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, { projectId }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] });
|
queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
57
frontend/src/hooks/useReviewHooks.ts
Normal file
57
frontend/src/hooks/useReviewHooks.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createReview, updateReview, deleteReview, updateReviewStatus } from '@/api/reviewApi';
|
||||||
|
import { ReviewRequest, ReviewResponse } from '@/types';
|
||||||
|
|
||||||
|
// 리뷰 생성 훅
|
||||||
|
export const useCreateReview = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<ReviewResponse, Error, { projectId: number; memberId: number; reviewData: ReviewRequest }>({
|
||||||
|
mutationFn: ({ projectId, memberId, reviewData }) => createReview(projectId, memberId, reviewData),
|
||||||
|
onSuccess: (_, { projectId, memberId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['reviewList', projectId, memberId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 리뷰 수정 훅
|
||||||
|
export const useUpdateReview = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<
|
||||||
|
ReviewResponse,
|
||||||
|
Error,
|
||||||
|
{ projectId: number; reviewId: number; memberId: number; reviewData: ReviewRequest }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ projectId, reviewId, memberId, reviewData }) =>
|
||||||
|
updateReview(projectId, reviewId, memberId, reviewData),
|
||||||
|
onSuccess: (_, { projectId, reviewId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 리뷰 삭제 훅
|
||||||
|
export const useDeleteReview = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, Error, { projectId: number; reviewId: number; memberId: number }>({
|
||||||
|
mutationFn: ({ projectId, reviewId, memberId }) => deleteReview(projectId, reviewId, memberId),
|
||||||
|
onSuccess: (_, { projectId, reviewId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 리뷰 상태 변경 훅
|
||||||
|
export const useUpdateReviewStatus = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<
|
||||||
|
ReviewResponse,
|
||||||
|
Error,
|
||||||
|
{ projectId: number; reviewId: number; memberId: number; reviewStatus: string }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ projectId, reviewId, memberId, reviewStatus }) =>
|
||||||
|
updateReviewStatus(projectId, reviewId, memberId, reviewStatus),
|
||||||
|
onSuccess: (_, { projectId, reviewId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['reviewDetail', projectId, reviewId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
@ -155,6 +155,93 @@ export const handlers = [
|
|||||||
return HttpResponse.json({});
|
return HttpResponse.json({});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
http.post('/api/projects/:projectId/label/auto', () => {
|
||||||
|
const response: AutoLabelingResponse = {
|
||||||
|
imageId: 1,
|
||||||
|
imageUrl: 'image-url.jpg',
|
||||||
|
data: `{
|
||||||
|
"version": "0.1.0",
|
||||||
|
"task_type": "cls",
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"label": "NG",
|
||||||
|
"color": "#FF0000",
|
||||||
|
"points": [[0, 0]],
|
||||||
|
"group_id": null,
|
||||||
|
"shape_type": "point",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"split": "none",
|
||||||
|
"imageHeight": 2000,
|
||||||
|
"imageWidth": 4000,
|
||||||
|
"imageDepth": 4
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
return HttpResponse.json(response);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// DELETE: 프로젝트 멤버 제거 핸들러
|
||||||
|
http.delete('/api/projects/:projectId/members', ({ params }) => {
|
||||||
|
const { projectId } = params;
|
||||||
|
|
||||||
|
return HttpResponse.json({ message: `프로젝트 ${projectId}에서 멤버 제거 성공` });
|
||||||
|
}),
|
||||||
|
// PUT: 프로젝트 멤버 권한 수정 핸들러
|
||||||
|
http.put('/api/projects/:projectId/members', () => {
|
||||||
|
return HttpResponse.json({});
|
||||||
|
}),
|
||||||
|
// POST: 워크스페이스 멤버 추가 핸들러
|
||||||
|
http.post('/api/workspaces/:workspaceId/members/:memberId', ({ params }) => {
|
||||||
|
const { workspaceId, memberId } = params;
|
||||||
|
|
||||||
|
if (!workspaceId || !memberId) {
|
||||||
|
const errorResponse: ErrorResponse = {
|
||||||
|
status: 400,
|
||||||
|
code: 1002,
|
||||||
|
message: '잘못된 요청입니다. 요청을 확인해주세요.',
|
||||||
|
isSuccess: false,
|
||||||
|
};
|
||||||
|
return HttpResponse.json(errorResponse, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 응답
|
||||||
|
const response: WorkspaceResponse = {
|
||||||
|
id: parseInt(workspaceId as string, 10),
|
||||||
|
memberId: parseInt(memberId as string, 10),
|
||||||
|
title: 'Workspace 1',
|
||||||
|
content: 'Workspace for testing',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return HttpResponse.json(response, { status: 200 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
// GET: 프로젝트 멤버 리스트 조회 핸들러 (가상)
|
||||||
|
// 실제 구현 시 API 경로와 메서드를 확인 후 업데이트 필요
|
||||||
|
http.get('/api/projects/:projectId/members', () => {
|
||||||
|
const members: MemberResponse[] = [
|
||||||
|
{ id: 1, nickname: 'admin', profileImage: 'admin.jpg' },
|
||||||
|
{ id: 2, nickname: 'editor', profileImage: 'editor.jpg' },
|
||||||
|
{ id: 3, nickname: 'viewer', profileImage: 'viewer.jpg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return HttpResponse.json(members);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// GET: 워크스페이스 멤버 리스트 조회 핸들러 (가상)
|
||||||
|
// 실제 구현 시 API 경로와 메서드를 확인 후 업데이트 필요
|
||||||
|
http.get('/api/workspaces/:workspaceId/members', () => {
|
||||||
|
const members: MemberResponse[] = [
|
||||||
|
{ id: 1, nickname: 'admin', profileImage: 'admin.jpg' },
|
||||||
|
{ id: 2, nickname: 'editor', profileImage: 'editor.jpg' },
|
||||||
|
{ id: 3, nickname: 'viewer', profileImage: 'viewer.jpg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return HttpResponse.json(members);
|
||||||
|
}),
|
||||||
|
|
||||||
// Folder and Image Handlers
|
// Folder and Image Handlers
|
||||||
http.get('/api/projects/:projectId/folders/:folderId', ({ params }) => {
|
http.get('/api/projects/:projectId/folders/:folderId', ({ params }) => {
|
||||||
const { folderId } = params;
|
const { folderId } = params;
|
||||||
|
15
frontend/src/pages/AdminIndex.tsx
Normal file
15
frontend/src/pages/AdminIndex.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Smile } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AdminIndex() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<Smile
|
||||||
|
size={48}
|
||||||
|
className="mb-2 text-gray-300"
|
||||||
|
/>
|
||||||
|
<div className="body text-gray-400">프로젝트를 선택하거나 생성하세요.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import GoogleLogo from '@/assets/icons/web_neutral_rd_ctn@1x.png';
|
import GoogleLogo from '@/assets/icons/web_neutral_rd_ctn@1x.png';
|
||||||
import useAuthStore from '@/stores/useAuthStore';
|
import useAuthStore from '@/stores/useAuthStore';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { getProfile, reissueToken } from '@/api/authApi';
|
import { getProfile } from '@/api/authApi';
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@ -17,20 +17,10 @@ export default function Home() {
|
|||||||
hasFetchedProfile.current = true;
|
hasFetchedProfile.current = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const handleGoogleSignIn = () => {
|
||||||
const handleReissueToken = async () => {
|
window.location.href = `${BASE_URL}/api/login/oauth2/authorization/google`;
|
||||||
try {
|
|
||||||
const response = await reissueToken();
|
|
||||||
console.log('토큰 재발급 성공:', response);
|
|
||||||
alert('토큰 재발급 성공! 새로운 액세스 토큰을 콘솔에서 확인하세요.');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('토큰 재발급 실패:', error);
|
|
||||||
alert('토큰 재발급에 실패했습니다. 다시 시도해 주세요.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isHidden = true;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center bg-gray-50 p-8">
|
<div className="flex h-full flex-col items-center justify-center bg-gray-50 p-8">
|
||||||
<div className="mb-6 max-w-xl rounded-lg bg-white p-6 shadow-lg">
|
<div className="mb-6 max-w-xl rounded-lg bg-white p-6 shadow-lg">
|
||||||
@ -53,9 +43,19 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoggedIn ? (
|
{!isLoggedIn ? (
|
||||||
<Link
|
// <Link
|
||||||
to={`${BASE_URL}/api/login/oauth2/authorization/google`}
|
// to={`${BASE_URL}/api/login/oauth2/authorization/google`}
|
||||||
// onClick={handleGoogleSignIn}
|
// // onClick={handleGoogleSignIn}
|
||||||
|
// className="mb-4 transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-gray-300 active:opacity-80"
|
||||||
|
// >
|
||||||
|
// <img
|
||||||
|
// src={GoogleLogo}
|
||||||
|
// alt="Sign in with Google"
|
||||||
|
// className="h-auto w-full"
|
||||||
|
// />
|
||||||
|
// </Link>
|
||||||
|
<button
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
className="mb-4 transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-gray-300 active:opacity-80"
|
className="mb-4 transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-gray-300 active:opacity-80"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@ -63,7 +63,7 @@ export default function Home() {
|
|||||||
alt="Sign in with Google"
|
alt="Sign in with Google"
|
||||||
className="h-auto w-full"
|
className="h-auto w-full"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</button> // 404 에러 방지
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@ -73,15 +73,6 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
<Link to="/browse">시작하기</Link>
|
<Link to="/browse">시작하기</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outlinePrimary"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleReissueToken}
|
|
||||||
className="mt-4"
|
|
||||||
style={{ display: isHidden ? 'none' : 'block' }}
|
|
||||||
>
|
|
||||||
리프레시 토큰 재발급 테스트
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
10
frontend/src/queries/useProjectMembersQuery.ts
Normal file
10
frontend/src/queries/useProjectMembersQuery.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { getProjectMembers } from '@/api/projectApi';
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { ProjectMemberResponse } from '@/types';
|
||||||
|
|
||||||
|
export default function useProjectMembersQuery(projectId: number, memberId: number) {
|
||||||
|
return useSuspenseQuery<ProjectMemberResponse[]>({
|
||||||
|
queryKey: ['projectMembers', projectId, memberId],
|
||||||
|
queryFn: () => getProjectMembers(projectId, memberId),
|
||||||
|
});
|
||||||
|
}
|
13
frontend/src/queries/useReviewByStatusQuery.ts
Normal file
13
frontend/src/queries/useReviewByStatusQuery.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { getReviewByStatus } from '@/api/reviewApi';
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export default function useReviewByStatusQuery(
|
||||||
|
projectId: number,
|
||||||
|
memberId: number,
|
||||||
|
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED' | undefined
|
||||||
|
) {
|
||||||
|
return useSuspenseQuery({
|
||||||
|
queryKey: ['reviewByStatus', projectId, reviewStatus],
|
||||||
|
queryFn: () => getReviewByStatus(projectId, memberId, reviewStatus),
|
||||||
|
});
|
||||||
|
}
|
9
frontend/src/queries/useReviewDetailQuery.ts
Normal file
9
frontend/src/queries/useReviewDetailQuery.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { getReviewDetail } from '@/api/reviewApi';
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export default function useReviewDetailQuery(projectId: number, reviewId: number, memberId: number) {
|
||||||
|
return useSuspenseQuery({
|
||||||
|
queryKey: ['reviewDetail', projectId, reviewId, memberId],
|
||||||
|
queryFn: () => getReviewDetail(projectId, reviewId, memberId),
|
||||||
|
});
|
||||||
|
}
|
@ -9,16 +9,13 @@ import ReviewList from '@/components/ReviewList';
|
|||||||
import AdminMemberManage from '@/components/AdminMemberManage';
|
import AdminMemberManage from '@/components/AdminMemberManage';
|
||||||
import OAuthCallback from '@/components/OAuthCallback';
|
import OAuthCallback from '@/components/OAuthCallback';
|
||||||
import { createBrowserRouter } from 'react-router-dom';
|
import { createBrowserRouter } from 'react-router-dom';
|
||||||
import { Navigate } from 'react-router-dom';
|
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import WorkspaceBrowseIndex from '@/pages/WorkspaceBrowseIndex';
|
import WorkspaceBrowseIndex from '@/pages/WorkspaceBrowseIndex';
|
||||||
|
import AdminIndex from '@/pages/AdminIndex';
|
||||||
export const webPath = {
|
export const webPath = {
|
||||||
home: () => '/',
|
home: () => '/',
|
||||||
browse: () => '/browse',
|
browse: () => '/browse',
|
||||||
workspace: () => '/workspace',
|
workspace: () => '/workspace',
|
||||||
// workspace: (workspaceId: string, projectId?: string) =>
|
|
||||||
// projectId ? `/workspace/${workspaceId}/project/${projectId}` : `/workspace/${workspaceId}`,
|
|
||||||
admin: () => `/admin`,
|
admin: () => `/admin`,
|
||||||
oauthCallback: () => '/redirect/oauth2',
|
oauthCallback: () => '/redirect/oauth2',
|
||||||
};
|
};
|
||||||
@ -73,15 +70,19 @@ const router = createBrowserRouter([
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `${webPath.admin()}/:workspaceId`,
|
path: `${webPath.admin()}/:workspaceId/project/:projectId?`,
|
||||||
element: <AdminLayout />,
|
element: (
|
||||||
|
<Suspense fallback={<div></div>}>
|
||||||
|
<AdminLayout />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
element: <Navigate to="review" />,
|
element: <AdminIndex />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'review',
|
path: 'reviews',
|
||||||
element: <ReviewList />,
|
element: <ReviewList />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -149,23 +149,52 @@ export interface AutoLabelingResponse {
|
|||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 리뷰 요청 DTO
|
||||||
export interface ReviewRequest {
|
export interface ReviewRequest {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageIds: number[];
|
imageIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 리뷰 응답 DTO
|
||||||
export interface ReviewResponse {
|
export interface ReviewResponse {
|
||||||
reviewId: number;
|
reviewId: number;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
status: 'REQUESTED' | 'APPROVED' | 'REJECTED';
|
status: 'REQUESTED' | 'APPROVED' | 'REJECTED';
|
||||||
|
nickname: string;
|
||||||
|
email: string;
|
||||||
|
createAt: string;
|
||||||
|
updateAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 리뷰 상태 요청 DTO
|
||||||
export interface ReviewStatusRequest {
|
export interface ReviewStatusRequest {
|
||||||
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED';
|
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 리뷰 이미지 응답 DTO
|
||||||
|
export interface ReviewImageResponse {
|
||||||
|
id: number; // 이미지 ID
|
||||||
|
imageTitle: string; // 이미지 파일 제목
|
||||||
|
status: 'PENDING' | 'IN_PROGRESS' | 'SAVE' | 'REVIEW_REQUEST' | 'COMPLETED';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리뷰 디테일 응답 DTO
|
||||||
|
export interface ReviewDetailResponse {
|
||||||
|
reviewId: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED';
|
||||||
|
images: ReviewImageResponse[];
|
||||||
|
}
|
||||||
|
// 프로젝트 멤버 응답 DTO
|
||||||
|
export interface ProjectMemberResponse {
|
||||||
|
memberId: number;
|
||||||
|
nickname: string;
|
||||||
|
profileImage: string;
|
||||||
|
privilegeType: 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER';
|
||||||
|
}
|
||||||
export interface FolderIdResponse {
|
export interface FolderIdResponse {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@ -175,7 +204,7 @@ export interface ImageDetailResponse {
|
|||||||
id: number;
|
id: number;
|
||||||
imageTitle: string;
|
imageTitle: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
data: string | null; // PENDING 상태라면 null
|
data: string | null;
|
||||||
status: 'PENDING' | 'IN_PROGRESS' | 'SAVE' | 'REVIEW_REQUEST' | 'COMPLETED';
|
status: 'PENDING' | 'IN_PROGRESS' | 'SAVE' | 'REVIEW_REQUEST' | 'COMPLETED';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,12 +221,16 @@ export interface LabelSaveRequest {
|
|||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewDetailResponse {
|
export interface ProjectMemberResponse {
|
||||||
reviewId: number;
|
memberId: number;
|
||||||
title: string;
|
nickname: string;
|
||||||
content: string;
|
profileImage: string;
|
||||||
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED';
|
privilegeType: 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER';
|
||||||
images: ImageResponse[];
|
}
|
||||||
|
|
||||||
|
export interface ProjectMemberRequest {
|
||||||
|
memberId: number;
|
||||||
|
privilegeType: 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
|
Loading…
Reference in New Issue
Block a user