Merge branch 'fe/feat/review' into 'fe/develop'

Feat: 리뷰 디테일 가안 구현

See merge request s11-s-project/S11P21S002!83
This commit is contained in:
김용수 2024-09-19 10:20:05 +09:00
commit 97816e30db
27 changed files with 754 additions and 587 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

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

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

View File

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

View File

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