Merge branch 'fe/feat/admin-page' into 'fe/develop'
Feat: admin page 구현 See merge request s11-s-project/S11P21S002!52
This commit is contained in:
commit
1ccdcbdf1f
44
frontend/src/components/AdminLayout/index.stories.tsx
Normal file
44
frontend/src/components/AdminLayout/index.stories.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import '@/index.css';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import AdminLayout from './index';
|
||||||
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
|
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: () => (
|
||||||
|
<Router>
|
||||||
|
<AdminLayout workspace={workspace} />
|
||||||
|
</Router>
|
||||||
|
),
|
||||||
|
};
|
32
frontend/src/components/AdminLayout/index.tsx
Normal file
32
frontend/src/components/AdminLayout/index.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import Header from '../Header';
|
||||||
|
import { ResizablePanelGroup, ResizablePanel } from '../ui/resizable';
|
||||||
|
import AdminProjectSidebar from '../AdminProjectSidebar';
|
||||||
|
import AdminMenuSidebar from '../AdminMenuSidebar';
|
||||||
|
import { Workspace } from '@/types';
|
||||||
|
|
||||||
|
interface AdminLayoutProps {
|
||||||
|
workspace: Workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLayout({ workspace }: AdminLayoutProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header className="fixed left-0 top-0" />
|
||||||
|
<div className="mt-16 h-[calc(100vh-64px)] w-screen">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
<AdminProjectSidebar
|
||||||
|
workspaceName={workspace.name}
|
||||||
|
projects={workspace.projects}
|
||||||
|
/>
|
||||||
|
<ResizablePanel className="flex w-full items-center">
|
||||||
|
<main className="h-full grow">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</ResizablePanel>
|
||||||
|
<AdminMenuSidebar />
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,139 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
|
|
||||||
|
type Role = 'admin' | 'editor' | 'viewer';
|
||||||
|
|
||||||
|
const roles: Role[] = ['admin', 'editor', 'viewer'];
|
||||||
|
|
||||||
|
const roleToStr: { [key in Role]: string } = {
|
||||||
|
admin: '관리자',
|
||||||
|
editor: '에디터',
|
||||||
|
viewer: '뷰어',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
members: z.array(
|
||||||
|
z.object({
|
||||||
|
email: z.string().email({ message: '올바른 이메일 형식을 입력해주세요.' }),
|
||||||
|
role: z.enum(roles as [Role, ...Role[]], { errorMap: () => ({ message: '역할을 선택해주세요.' }) }),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MemberManageFormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminMemberManageFormProps {
|
||||||
|
members: Member[];
|
||||||
|
onSubmit: (data: MemberManageFormValues) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminMemberManageForm({ members, onSubmit }: AdminMemberManageFormProps) {
|
||||||
|
const form = useForm<MemberManageFormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: { members },
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedMembers = members.reduce<{ [key: string]: { email: string; role: Role }[] }>((acc, member) => {
|
||||||
|
if (!acc[member.role]) acc[member.role] = [];
|
||||||
|
acc[member.role].push(member);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const roleOrder: Role[] = ['admin', 'editor', 'viewer'];
|
||||||
|
|
||||||
|
const sortedGroupedMembers = Object.entries(groupedMembers).sort(
|
||||||
|
([roleA], [roleB]) => roleOrder.indexOf(roleA as Role) - roleOrder.indexOf(roleB as Role)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
{sortedGroupedMembers.map(([role, groupMembers]) => {
|
||||||
|
if (!groupMembers || groupMembers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={role}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name={`members.${members.findIndex((m) => m.email === member.email)}.email`}
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="email@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name={`members.${members.findIndex((m) => m.email === member.email)}.role`}
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="역할을 선택해주세요." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<SelectItem
|
||||||
|
key={role}
|
||||||
|
value={role}
|
||||||
|
>
|
||||||
|
{roleToStr[role]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outlinePrimary"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
>
|
||||||
|
역할 설정
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
40
frontend/src/components/AdminMemberManage/index.stories.tsx
Normal file
40
frontend/src/components/AdminMemberManage/index.stories.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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),
|
||||||
|
},
|
||||||
|
};
|
64
frontend/src/components/AdminMemberManage/index.tsx
Normal file
64
frontend/src/components/AdminMemberManage/index.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
|
import AdminMemberManageForm, { MemberManageFormValues } from './AdminMemberManageForm';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
type Role = 'admin' | 'editor' | 'viewer';
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminMemberManage({
|
||||||
|
title = '멤버 관리',
|
||||||
|
projects,
|
||||||
|
onProjectChange,
|
||||||
|
onSubmit,
|
||||||
|
members,
|
||||||
|
onMemberInvite,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
projects: Project[];
|
||||||
|
onProjectChange: (projectId: string) => void;
|
||||||
|
onSubmit: (data: MemberManageFormValues) => void;
|
||||||
|
members: Member[];
|
||||||
|
onMemberInvite: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<h1 className="flex-1 text-lg font-semibold text-[#333238]">{title}</h1>
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
|
<AdminMemberManageForm
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
members={members}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
36
frontend/src/components/AdminMenuSidebar/index.tsx
Normal file
36
frontend/src/components/AdminMenuSidebar/index.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function AdminMenuSidebar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ label: '작업', path: '/admin/tasks' },
|
||||||
|
{ label: '리뷰', path: '/admin/reviews' },
|
||||||
|
{ label: '멤버 관리', path: '/admin/members' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-[280px] flex-col justify-between border-l border-gray-300 bg-gray-100">
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<header className="subheading flex w-full items-center gap-2 px-5 py-2.5">
|
||||||
|
<h2 className="w-full overflow-hidden text-ellipsis whitespace-nowrap">메뉴</h2>
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-col gap-1 px-2.5">
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
className={cn(
|
||||||
|
'body cursor-pointer rounded-md px-3 py-2 text-left text-gray-800 hover:bg-gray-200',
|
||||||
|
'transition-colors focus:bg-gray-300 focus:outline-none'
|
||||||
|
)}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
54
frontend/src/components/AdminProjectSidebar/index.tsx
Normal file
54
frontend/src/components/AdminProjectSidebar/index.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { ResizablePanel, ResizableHandle } from '../ui/resizable';
|
||||||
|
import { Project } from '@/types';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { SquarePen } from 'lucide-react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
interface AdminProjectSidebarProps {
|
||||||
|
workspaceName: string;
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminProjectSidebar({ workspaceName, projects }: AdminProjectSidebarProps): JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ResizablePanel
|
||||||
|
minSize={15}
|
||||||
|
maxSize={35}
|
||||||
|
defaultSize={20}
|
||||||
|
className="flex h-full flex-col border-r border-gray-200 bg-gray-100"
|
||||||
|
>
|
||||||
|
<header className="flex w-full items-center justify-between gap-2 border-b border-gray-200 p-4">
|
||||||
|
<h1 className="heading w-full overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900">
|
||||||
|
{workspaceName}
|
||||||
|
</h1>
|
||||||
|
<button className="p-2">
|
||||||
|
<SquarePen size={16} />
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="caption border-gray-800 bg-gray-100"
|
||||||
|
onClick={() => console.log('New project')}
|
||||||
|
>
|
||||||
|
새 프로젝트
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-col gap-2 p-4">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
className="body cursor-pointer rounded-md px-3 py-2 text-left hover:bg-gray-200"
|
||||||
|
onClick={() => navigate(`/project/${project.id}`)}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle className="bg-gray-300" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
55
frontend/src/components/ReviewList/ReviewItem.tsx
Normal file
55
frontend/src/components/ReviewList/ReviewItem.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Briefcase, Tag, Box, Layers, Pen } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ReviewItemProps {
|
||||||
|
title: string;
|
||||||
|
createdTime: string;
|
||||||
|
creatorName: string;
|
||||||
|
project: string;
|
||||||
|
status: string;
|
||||||
|
type: { text: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; color: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeIcons: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', JSX.Element> = {
|
||||||
|
Classification: <Tag 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" />,
|
||||||
|
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) {
|
||||||
|
const icon = typeIcons[type.text];
|
||||||
|
const bgColor = typeStyles[type.text];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[100px] w-full items-center justify-between border-b-[0.67px] border-[#ececef] bg-[#fbfafd] p-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-sm font-semibold text-[#333238]">{title}</p>
|
||||||
|
<p className="mt-1 text-xs text-[#737278]">by {creatorName}</p>
|
||||||
|
<div className="mt-1 flex items-center">
|
||||||
|
<Briefcase className="h-3 w-3 text-[#737278]" />
|
||||||
|
<p className="ml-1 text-xs text-[#737278]">{project}</p>
|
||||||
|
</div>
|
||||||
|
{type && (
|
||||||
|
<div
|
||||||
|
className="mt-1 inline-flex max-w-fit items-center gap-1 rounded-full px-3 py-1 text-xs text-white"
|
||||||
|
style={{ backgroundColor: bgColor, padding: '1px 5px' }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{type.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="rounded-full bg-[#cbe2f9] px-3 py-0.5 text-center text-xs text-[#0b5cad]">{status}</div>
|
||||||
|
<p className="text-xs text-[#737278]">Created at {createdTime}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
63
frontend/src/components/ReviewList/index.stories.tsx
Normal file
63
frontend/src/components/ReviewList/index.stories.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import '@/index.css';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import ReviewList from '.';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ReviewList> = {
|
||||||
|
title: 'Components/ReviewList',
|
||||||
|
component: ReviewList,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof ReviewList>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
acceptedCount: 10,
|
||||||
|
rejectedCount: 5,
|
||||||
|
pendingCount: 7,
|
||||||
|
totalCount: 22,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: '갤럭시22 생산 라인 이물질 분류',
|
||||||
|
createdTime: '2 hours ago',
|
||||||
|
creatorName: 'Kim Tae Su',
|
||||||
|
project: 'Project A',
|
||||||
|
type: 'Classification',
|
||||||
|
status: 'needs_review',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '갤럭시 흠집 객체 탐지',
|
||||||
|
createdTime: '3 hours ago',
|
||||||
|
creatorName: 'Kim Tae Su',
|
||||||
|
project: 'Project B',
|
||||||
|
type: 'Detection',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '갤럭시 흠집 경계 폴리곤',
|
||||||
|
createdTime: '5 hours ago',
|
||||||
|
creatorName: 'Kim Tae Su',
|
||||||
|
project: 'Project C',
|
||||||
|
type: 'Polygon',
|
||||||
|
status: 'in_progress',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '갤럭시 흠집 폴리라인',
|
||||||
|
createdTime: '1 day ago',
|
||||||
|
creatorName: 'Kim Tae Su',
|
||||||
|
project: 'Project D',
|
||||||
|
type: 'Polyline',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '갤럭시 흠집 디텍션 허가 요청',
|
||||||
|
createdTime: '6 hours ago',
|
||||||
|
creatorName: 'Kim Tae Su',
|
||||||
|
project: 'Project E',
|
||||||
|
type: 'Detection',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ReviewRequestItem from './ReviewRequestItem';
|
import ReviewItem from './ReviewItem';
|
||||||
import ReviewSearchInput from './ReviewSearchInput';
|
import ReviewSearchInput from './ReviewSearchInput';
|
||||||
|
|
||||||
interface ReviewRequestProps {
|
interface ReviewListProps {
|
||||||
acceptedCount: number;
|
acceptedCount: number;
|
||||||
rejectedCount: number;
|
rejectedCount: number;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
@ -14,9 +14,6 @@ interface ReviewRequestProps {
|
|||||||
project: string;
|
project: string;
|
||||||
type: 'Classification' | 'Detection' | 'Polygon' | 'Polyline';
|
type: 'Classification' | 'Detection' | 'Polygon' | 'Polyline';
|
||||||
status: string;
|
status: string;
|
||||||
commentsCount: number;
|
|
||||||
updatesCount: number;
|
|
||||||
lastUpdated: string;
|
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,13 +24,13 @@ const typeColors: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline'
|
|||||||
Polyline: '#c5f9d4',
|
Polyline: '#c5f9d4',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReviewRequest({
|
export default function ReviewList({
|
||||||
acceptedCount,
|
acceptedCount,
|
||||||
rejectedCount,
|
rejectedCount,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
items,
|
items,
|
||||||
}: ReviewRequestProps): JSX.Element {
|
}: ReviewListProps): JSX.Element {
|
||||||
const [activeTab, setActiveTab] = useState('pending');
|
const [activeTab, setActiveTab] = useState('pending');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sortValue, setSortValue] = useState('latest');
|
const [sortValue, setSortValue] = useState('latest');
|
||||||
@ -42,7 +39,8 @@ export default function ReviewRequest({
|
|||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (activeTab === 'pending') return item.status.toLowerCase() === 'needs_review';
|
if (activeTab === 'pending') return item.status.toLowerCase() === 'needs_review';
|
||||||
if (activeTab === 'accepted') return item.status.toLowerCase() === 'completed';
|
if (activeTab === 'accepted') return item.status.toLowerCase() === 'completed';
|
||||||
if (activeTab === 'rejected') return ['in_progress', 'pending'].includes(item.status.toLowerCase());
|
if (activeTab === 'rejected')
|
||||||
|
return item.status.toLowerCase() === 'in_progress' || item.status.toLowerCase() === 'pending';
|
||||||
if (activeTab === 'all') return true;
|
if (activeTab === 'all') return true;
|
||||||
return false;
|
return false;
|
||||||
})
|
})
|
||||||
@ -51,10 +49,6 @@ export default function ReviewRequest({
|
|||||||
switch (sortValue) {
|
switch (sortValue) {
|
||||||
case 'oldest':
|
case 'oldest':
|
||||||
return new Date(a.createdTime).getTime() - new Date(b.createdTime).getTime();
|
return new Date(a.createdTime).getTime() - new Date(b.createdTime).getTime();
|
||||||
case 'comments':
|
|
||||||
return b.commentsCount - a.commentsCount;
|
|
||||||
case 'updates':
|
|
||||||
return b.updatesCount - a.updatesCount;
|
|
||||||
default:
|
default:
|
||||||
return new Date(b.createdTime).getTime() - new Date(a.createdTime).getTime();
|
return new Date(b.createdTime).getTime() - new Date(a.createdTime).getTime();
|
||||||
}
|
}
|
||||||
@ -132,16 +126,13 @@ export default function ReviewRequest({
|
|||||||
|
|
||||||
<div className="relative w-full overflow-y-auto px-4">
|
<div className="relative w-full overflow-y-auto px-4">
|
||||||
{filteredItems.map((item, index) => (
|
{filteredItems.map((item, index) => (
|
||||||
<ReviewRequestItem
|
<ReviewItem
|
||||||
key={index}
|
key={index}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
createdTime={item.createdTime}
|
createdTime={item.createdTime}
|
||||||
creatorName={item.creatorName}
|
creatorName={item.creatorName}
|
||||||
project={item.project}
|
project={item.project}
|
||||||
status={item.status}
|
status={item.status}
|
||||||
commentsCount={item.commentsCount}
|
|
||||||
updatesCount={item.updatesCount}
|
|
||||||
lastUpdated={item.lastUpdated}
|
|
||||||
type={{ text: item.type, color: typeColors[item.type] }}
|
type={{ text: item.type, color: typeColors[item.type] }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
@ -1,39 +0,0 @@
|
|||||||
import '@/index.css';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import ReviewRequestItem from './ReviewRequestItem';
|
|
||||||
|
|
||||||
const meta: Meta<typeof ReviewRequestItem> = {
|
|
||||||
title: 'Components/ReviewRequestItem',
|
|
||||||
component: ReviewRequestItem,
|
|
||||||
argTypes: {
|
|
||||||
title: { control: 'text', description: 'Title of the review request' },
|
|
||||||
createdTime: { control: 'text', description: 'Time when the review was created' },
|
|
||||||
creatorName: { control: 'text', description: 'Name of the creator' },
|
|
||||||
project: { control: 'text', description: 'Project name' },
|
|
||||||
status: { control: 'text', description: 'Status of the review request' },
|
|
||||||
commentsCount: { control: 'number', description: 'Number of comments' },
|
|
||||||
updatesCount: { control: 'number', description: 'Number of updates' },
|
|
||||||
lastUpdated: { control: 'text', description: 'Time when the review was last updated' },
|
|
||||||
type: {
|
|
||||||
control: 'object',
|
|
||||||
description: 'Label for the request type with text and color',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ReviewRequestItem>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
title: 'Feat: 워크스페이스 멤버 추가 구현 - S11P21S002-37',
|
|
||||||
createdTime: '!24 · created 2 hours ago',
|
|
||||||
creatorName: '김태수',
|
|
||||||
project: 'be/develop',
|
|
||||||
status: 'Merged',
|
|
||||||
commentsCount: 4,
|
|
||||||
updatesCount: 1,
|
|
||||||
lastUpdated: 'updated 1 hour ago',
|
|
||||||
type: { text: 'Classification', color: '#a2eeef' },
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,6 +1,6 @@
|
|||||||
import { Briefcase, MessageCircle, RefreshCw, Tag, Box, Layers, Pen } from 'lucide-react';
|
import { Briefcase, MessageCircle, RefreshCw, Tag, Box, Layers, Pen } from 'lucide-react';
|
||||||
|
|
||||||
interface ReviewRequestItemProps {
|
interface TaskItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
createdTime: string;
|
createdTime: string;
|
||||||
creatorName: string;
|
creatorName: string;
|
||||||
@ -9,7 +9,9 @@ interface ReviewRequestItemProps {
|
|||||||
commentsCount: number;
|
commentsCount: number;
|
||||||
updatesCount: number;
|
updatesCount: number;
|
||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
type: { text: 'Classification' | 'Detection' | 'Polygon' | 'Polyline'; color: string };
|
type: 'Classification' | 'Detection' | 'Polygon' | 'Polyline';
|
||||||
|
bgColor?: string;
|
||||||
|
memberCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeIcons: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', JSX.Element> = {
|
const typeIcons: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', JSX.Element> = {
|
||||||
@ -19,14 +21,7 @@ const typeIcons: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline',
|
|||||||
Polyline: <Pen className="h-4 w-4 text-white" />,
|
Polyline: <Pen className="h-4 w-4 text-white" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeStyles: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', string> = {
|
export default function TaskItem({
|
||||||
Classification: '#a2eeef',
|
|
||||||
Detection: '#d4c5f9',
|
|
||||||
Polygon: '#f9c5d4',
|
|
||||||
Polyline: '#c5f9d4',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ReviewRequestItem({
|
|
||||||
title,
|
title,
|
||||||
createdTime,
|
createdTime,
|
||||||
creatorName,
|
creatorName,
|
||||||
@ -36,12 +31,13 @@ export default function ReviewRequestItem({
|
|||||||
updatesCount,
|
updatesCount,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
type,
|
type,
|
||||||
}: ReviewRequestItemProps) {
|
bgColor = '#f3f4f6',
|
||||||
const icon = typeIcons[type.text];
|
memberCount,
|
||||||
const bgColor = typeStyles[type.text];
|
}: TaskItemProps) {
|
||||||
|
const icon = typeIcons[type];
|
||||||
|
|
||||||
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-[120px] w-full items-center justify-between border-b-[0.67px] border-[#ececef] bg-[#fbfafd] p-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-sm font-semibold text-[#333238]">{title}</p>
|
<p className="text-sm font-semibold text-[#333238]">{title}</p>
|
||||||
<p className="mt-1 text-xs text-[#737278]">
|
<p className="mt-1 text-xs text-[#737278]">
|
||||||
@ -51,15 +47,13 @@ export default function ReviewRequestItem({
|
|||||||
<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}</p>
|
||||||
</div>
|
</div>
|
||||||
{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: bgColor, 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}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -73,7 +67,8 @@ export default function ReviewRequestItem({
|
|||||||
<span className="text-sm">{updatesCount}</span>
|
<span className="text-sm">{updatesCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#737278]">{lastUpdated}</p>
|
<p className="text-xs text-[#737278]">Updated at {lastUpdated}</p>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{`멤버 ${memberCount}명`}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
48
frontend/src/components/TaskList/TaskSearchInput.tsx
Normal file
48
frontend/src/components/TaskList/TaskSearchInput.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
|
import SearchInput from '@/components/ui/search-input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: 'latest', label: '최신 순' },
|
||||||
|
{ value: 'oldest', label: '오래된 순' },
|
||||||
|
{ value: 'comments', label: '댓글 많은 순' },
|
||||||
|
{ value: 'updates', label: '업데이트 많은 순' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TaskSearchInputProps {
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onSortChange: (value: string) => void;
|
||||||
|
sortValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskSearchInput({ onSearchChange, onSortChange, sortValue }: TaskSearchInputProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex w-full items-center justify-between border-b-[0.67px] border-[#ececef] bg-[#fbfafd] p-4')}>
|
||||||
|
<div className="flex flex-1 items-center gap-4">
|
||||||
|
<SearchInput
|
||||||
|
className="flex-1"
|
||||||
|
placeholder="검색 또는 필터..."
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={sortValue}
|
||||||
|
onValueChange={onSortChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] rounded-md border border-gray-200">
|
||||||
|
<SelectValue placeholder="정렬 기준 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,15 +1,14 @@
|
|||||||
import '@/index.css';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import ReviewRequest from '.';
|
import TaskList from '.';
|
||||||
|
|
||||||
const meta: Meta<typeof ReviewRequest> = {
|
const meta: Meta<typeof TaskList> = {
|
||||||
title: 'Components/ReviewRequest',
|
title: 'Components/TaskList',
|
||||||
component: ReviewRequest,
|
component: TaskList,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof ReviewRequest>;
|
type Story = StoryObj<typeof TaskList>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
@ -28,6 +27,7 @@ export const Default: Story = {
|
|||||||
commentsCount: 4,
|
commentsCount: 4,
|
||||||
updatesCount: 1,
|
updatesCount: 1,
|
||||||
lastUpdated: '1 hour ago',
|
lastUpdated: '1 hour ago',
|
||||||
|
memberCount: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '갤럭시 흠집 객체 탐지',
|
title: '갤럭시 흠집 객체 탐지',
|
||||||
@ -39,6 +39,7 @@ export const Default: Story = {
|
|||||||
commentsCount: 2,
|
commentsCount: 2,
|
||||||
updatesCount: 3,
|
updatesCount: 3,
|
||||||
lastUpdated: '30 minutes ago',
|
lastUpdated: '30 minutes ago',
|
||||||
|
memberCount: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '갤럭시 흠집 경계 폴리곤',
|
title: '갤럭시 흠집 경계 폴리곤',
|
||||||
@ -50,6 +51,7 @@ export const Default: Story = {
|
|||||||
commentsCount: 3,
|
commentsCount: 3,
|
||||||
updatesCount: 2,
|
updatesCount: 2,
|
||||||
lastUpdated: '2 hours ago',
|
lastUpdated: '2 hours ago',
|
||||||
|
memberCount: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '갤럭시 흠집 폴리라인',
|
title: '갤럭시 흠집 폴리라인',
|
||||||
@ -61,9 +63,10 @@ export const Default: Story = {
|
|||||||
commentsCount: 5,
|
commentsCount: 5,
|
||||||
updatesCount: 0,
|
updatesCount: 0,
|
||||||
lastUpdated: '20 minutes ago',
|
lastUpdated: '20 minutes ago',
|
||||||
|
memberCount: 6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '갤럭시 흠집 디텍션 허가 요청',
|
title: '갤럭시 흠집 디텍션',
|
||||||
createdTime: '6 hours ago',
|
createdTime: '6 hours ago',
|
||||||
creatorName: 'Kim Tae Su',
|
creatorName: 'Kim Tae Su',
|
||||||
project: 'Project E',
|
project: 'Project E',
|
||||||
@ -72,6 +75,7 @@ export const Default: Story = {
|
|||||||
commentsCount: 1,
|
commentsCount: 1,
|
||||||
updatesCount: 4,
|
updatesCount: 4,
|
||||||
lastUpdated: '3 hours ago',
|
lastUpdated: '3 hours ago',
|
||||||
|
memberCount: 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
145
frontend/src/components/TaskList/index.tsx
Normal file
145
frontend/src/components/TaskList/index.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import TaskItem from './TaskItem';
|
||||||
|
import TaskSearchInput from './TaskSearchInput';
|
||||||
|
|
||||||
|
interface TaskListProps {
|
||||||
|
acceptedCount: number;
|
||||||
|
rejectedCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
items: {
|
||||||
|
title: string;
|
||||||
|
createdTime: string;
|
||||||
|
creatorName: string;
|
||||||
|
project: string;
|
||||||
|
type: 'Classification' | 'Detection' | 'Polygon' | 'Polyline';
|
||||||
|
status: string;
|
||||||
|
commentsCount: number;
|
||||||
|
updatesCount: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
memberCount: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline', string> = {
|
||||||
|
Classification: '#a2eeef',
|
||||||
|
Detection: '#d4c5f9',
|
||||||
|
Polygon: '#f9c5d4',
|
||||||
|
Polyline: '#c5f9d4',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskList({ acceptedCount, pendingCount, totalCount, items }: TaskListProps): JSX.Element {
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sortValue, setSortValue] = useState('latest');
|
||||||
|
|
||||||
|
const filteredItems = items
|
||||||
|
.filter((item) => {
|
||||||
|
if (activeTab === 'in_progress') return item.status.toLowerCase() === 'in_progress';
|
||||||
|
if (activeTab === 'completed') return item.status.toLowerCase() === 'completed';
|
||||||
|
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();
|
||||||
|
case 'comments':
|
||||||
|
return b.commentsCount - a.commentsCount;
|
||||||
|
case 'updates':
|
||||||
|
return b.updatesCount - a.updatesCount;
|
||||||
|
default:
|
||||||
|
return new Date(b.createdTime).getTime() - new Date(a.createdTime).getTime();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div className="relative w-full px-4">
|
||||||
|
<div className="flex w-full items-center border-b-[0.67px] border-solid border-[#dcdcde]">
|
||||||
|
<button
|
||||||
|
className={`flex h-12 w-[100px] items-center justify-between px-3 ${
|
||||||
|
activeTab === 'all' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
activeTab === 'all' ? 'font-semibold text-[#333238]' : 'font-normal text-[#737278]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
전체
|
||||||
|
</span>
|
||||||
|
<span className="flex h-4 w-6 items-center justify-center rounded-[160px] bg-[#ececef] text-xs text-[#626168]">
|
||||||
|
{totalCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`flex h-12 w-[100px] items-center justify-between px-3 ${
|
||||||
|
activeTab === 'in_progress' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab('in_progress')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
activeTab === 'in_progress' ? 'font-semibold text-[#333238]' : 'font-normal text-[#737278]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
진행중
|
||||||
|
</span>
|
||||||
|
<span className="flex h-4 w-6 items-center justify-center rounded-[160px] bg-[#ececef] text-xs text-[#626168]">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`flex h-12 w-[100px] items-center justify-between px-3 ${
|
||||||
|
activeTab === 'completed' ? 'shadow-[inset_0px_-2px_0px_#1f75cb]' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab('completed')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
activeTab === 'completed' ? 'font-semibold text-[#333238]' : 'font-normal text-[#737278]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
완료
|
||||||
|
</span>
|
||||||
|
<span className="flex h-4 w-6 items-center justify-center rounded-[160px] bg-[#ececef] text-xs text-[#626168]">
|
||||||
|
{acceptedCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full px-4">
|
||||||
|
<TaskSearchInput
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
onSortChange={setSortValue}
|
||||||
|
sortValue={sortValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full overflow-y-auto px-4">
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<TaskItem
|
||||||
|
key={index}
|
||||||
|
title={item.title}
|
||||||
|
createdTime={item.createdTime}
|
||||||
|
creatorName={item.creatorName}
|
||||||
|
project={item.project}
|
||||||
|
status={item.status}
|
||||||
|
commentsCount={item.commentsCount}
|
||||||
|
updatesCount={item.updatesCount}
|
||||||
|
lastUpdated={item.lastUpdated}
|
||||||
|
type={item.type}
|
||||||
|
bgColor={typeColors[item.type]}
|
||||||
|
memberCount={item.memberCount}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user