Merge branch 'fe/develop' of https://lab.ssafy.com/s11-s-project/S11P21S002 into fe/feat/admin-review-request
This commit is contained in:
commit
8aec3342e3
@ -47,11 +47,11 @@ export async function uploadImageFile(memberId: number, projectId: number, folde
|
||||
export async function uploadImageFolder(memberId: number, projectId: number, files: File[]) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('folderZip', file);
|
||||
formData.append('imageList', file);
|
||||
});
|
||||
|
||||
return api
|
||||
.post(`/projects/${projectId}/folders/${0}/images/zip`, formData, {
|
||||
.post(`/projects/${projectId}/folders/${0}/images/file`, formData, {
|
||||
params: { memberId },
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import api from '@/api/axiosConfig';
|
||||
import { ProjectListResponse, ProjectResponse, ProjectMemberRequest, ProjectMemberResponse } from '@/types';
|
||||
import { ProjectResponse, ProjectMemberRequest, ProjectMemberResponse } from '@/types';
|
||||
|
||||
export async function getProjectList(
|
||||
workspaceId: number,
|
||||
@ -8,7 +8,7 @@ export async function getProjectList(
|
||||
limit: number = 50
|
||||
) {
|
||||
return api
|
||||
.get<ProjectListResponse>(`/workspaces/${workspaceId}/projects`, {
|
||||
.get<ProjectResponse[]>(`/workspaces/${workspaceId}/projects`, {
|
||||
params: {
|
||||
memberId,
|
||||
lastProjectId,
|
||||
|
@ -1,21 +1,22 @@
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AdminMenuSidebar() {
|
||||
const location = useLocation();
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: '리뷰',
|
||||
path: `/admin/${workspaceId}/reviews`,
|
||||
path: projectId ? `/admin/${workspaceId}/reviews/${projectId}` : `/admin/${workspaceId}/reviews`,
|
||||
},
|
||||
{
|
||||
label: '멤버 관리',
|
||||
path: `/admin/${workspaceId}/members`,
|
||||
path: projectId ? `/admin/${workspaceId}/members/${projectId}` : `/admin/${workspaceId}/members`,
|
||||
},
|
||||
{
|
||||
label: '모델 관리',
|
||||
path: `/admin/${workspaceId}/models`,
|
||||
path: projectId ? `/admin/${workspaceId}/models/${projectId}` : `/admin/${workspaceId}/models`,
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ResizablePanel, ResizableHandle } from '../ui/resizable';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { SquarePen } from 'lucide-react';
|
||||
import useProjectListQuery from '@/queries/projects/useProjectListQuery';
|
||||
import useCreateProjectQuery from '@/queries/projects/useCreateProjectQuery';
|
||||
@ -11,7 +11,6 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AdminProjectSidebar(): JSX.Element {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { workspaceId, projectId } = useParams<{ workspaceId: string; projectId?: string }>();
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
const memberId = profile?.id || 0;
|
||||
@ -19,8 +18,7 @@ export default function AdminProjectSidebar(): JSX.Element {
|
||||
const { data: workspaceData } = useWorkspaceQuery(Number(workspaceId), memberId);
|
||||
const workspaceTitle = workspaceData?.title || `Workspace-${workspaceId}`;
|
||||
|
||||
const { data: projectsResponse } = useProjectListQuery(Number(workspaceId), memberId);
|
||||
const projects = projectsResponse?.workspaceResponses ?? [];
|
||||
const { data: projects } = useProjectListQuery(Number(workspaceId), memberId);
|
||||
|
||||
const createProject = useCreateProjectQuery();
|
||||
|
||||
@ -32,13 +30,6 @@ export default function AdminProjectSidebar(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: '',
|
||||
});
|
||||
};
|
||||
|
||||
const getNewPath = (newProjectId: string) => {
|
||||
if (location.pathname.includes('reviews')) {
|
||||
return `/admin/${workspaceId}/reviews/${newProjectId}`;
|
||||
@ -52,6 +43,9 @@ export default function AdminProjectSidebar(): JSX.Element {
|
||||
return location.pathname;
|
||||
};
|
||||
|
||||
const basePath = location.pathname.split('/')[3];
|
||||
const basePathWithoutProjectId = `/admin/${workspaceId}/${basePath}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResizablePanel
|
||||
@ -61,12 +55,12 @@ export default function AdminProjectSidebar(): JSX.Element {
|
||||
className="flex h-full flex-col border-r border-gray-200 bg-gray-100"
|
||||
>
|
||||
<header className="flex w-full items-center justify-between gap-2 border-b border-gray-200 p-4">
|
||||
<h1
|
||||
className="heading w-full cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900"
|
||||
onClick={handleHeaderClick}
|
||||
<Link
|
||||
to={basePathWithoutProjectId}
|
||||
className="heading w-full overflow-hidden text-ellipsis whitespace-nowrap text-xl font-bold text-gray-900"
|
||||
>
|
||||
{workspaceTitle}
|
||||
</h1>
|
||||
</Link>
|
||||
<button className="p-2">
|
||||
<SquarePen size={16} />
|
||||
</button>
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Label } from '@/types';
|
||||
import Konva from 'konva';
|
||||
import { useState } from 'react';
|
||||
import { Line } from 'react-konva';
|
||||
import PolygonTransformer from './PolygonTransformer';
|
||||
|
||||
@ -8,21 +7,25 @@ export default function LabelPolygon({
|
||||
isSelected,
|
||||
onSelect,
|
||||
info,
|
||||
setLabel,
|
||||
stage,
|
||||
dragLayer,
|
||||
}: {
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
info: Label;
|
||||
setLabel: (coordinate: [number, number][]) => void;
|
||||
stage: Konva.Stage;
|
||||
dragLayer: Konva.Layer;
|
||||
}) {
|
||||
const [coordinates, setCoordinates] = useState<Array<[number, number]>>(info.coordinates);
|
||||
const handleChange = (coordinates: [number, number][]) => {
|
||||
setLabel(coordinates);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Line
|
||||
points={coordinates.flat()}
|
||||
points={info.coordinates.flat()}
|
||||
stroke={info.color}
|
||||
strokeWidth={1}
|
||||
onMouseDown={onSelect}
|
||||
@ -33,8 +36,8 @@ export default function LabelPolygon({
|
||||
/>
|
||||
{isSelected && (
|
||||
<PolygonTransformer
|
||||
coordinates={coordinates}
|
||||
setCoordinates={setCoordinates}
|
||||
coordinates={info.coordinates}
|
||||
setCoordinates={handleChange}
|
||||
stage={stage}
|
||||
dragLayer={dragLayer}
|
||||
/>
|
||||
|
@ -119,10 +119,10 @@ export default function ImageCanvas() {
|
||||
setPolygonPoints([]);
|
||||
if (polygonPoints.length < 4) return;
|
||||
|
||||
const color = Math.floor(Math.random() * 65535)
|
||||
const color = Math.floor(Math.random() * 0xffffff)
|
||||
.toString(16)
|
||||
.padStart(6, '0');
|
||||
const id = labels.length + 1;
|
||||
const id = labels.length;
|
||||
addLabel({
|
||||
id: id,
|
||||
name: 'label',
|
||||
@ -146,7 +146,7 @@ export default function ImageCanvas() {
|
||||
return;
|
||||
}
|
||||
setRectPoints([]);
|
||||
const color = Math.floor(Math.random() * 65535)
|
||||
const color = Math.floor(Math.random() * 0xffffff)
|
||||
.toString(16)
|
||||
.padStart(6, '0');
|
||||
const id = labels.length;
|
||||
@ -289,6 +289,7 @@ export default function ImageCanvas() {
|
||||
isSelected={label.id === selectedLabelId}
|
||||
onSelect={() => setSelectedLabelId(label.id)}
|
||||
info={label}
|
||||
setLabel={setLabel(label.id)}
|
||||
stage={stageRef.current as Konva.Stage}
|
||||
dragLayer={dragLayerRef.current as Konva.Layer}
|
||||
/>
|
||||
|
@ -9,12 +9,10 @@ import SearchInput from '../ui/search-input';
|
||||
import useSearchMembersByEmailQuery from '@/queries/members/useSearchMembersByEmailQuery';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
type PrivilegeType = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER';
|
||||
|
||||
const privilegeTypes: readonly ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER'] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER'];
|
||||
type PrivilegeType = 'MANAGER' | 'EDITOR' | 'VIEWER'; // ADMIN을 제외
|
||||
const privilegeTypes: readonly ['MANAGER', 'EDITOR', 'VIEWER'] = ['MANAGER', 'EDITOR', 'VIEWER']; // ADMIN을 제외
|
||||
|
||||
const privilegeTypeToStr: { [key in PrivilegeType]: string } = {
|
||||
ADMIN: '관리자',
|
||||
MANAGER: '매니저',
|
||||
EDITOR: '에디터',
|
||||
VIEWER: '뷰어',
|
||||
|
55
frontend/src/components/ModelManage/EvaluationTab.tsx
Normal file
55
frontend/src/components/ModelManage/EvaluationTab.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import ModelBarChart from '@/components/ModelBarChart';
|
||||
|
||||
interface EvaluationTabProps {
|
||||
selectedModel: string | null;
|
||||
setSelectedModel: (model: string | null) => void;
|
||||
}
|
||||
|
||||
export default function EvaluationTab({ selectedModel, setSelectedModel }: EvaluationTabProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="select-model">모델 선택</Label>
|
||||
<Select onValueChange={setSelectedModel}>
|
||||
<SelectTrigger id="select-model">
|
||||
<SelectValue placeholder="모델을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="genesis">Genesis</SelectItem>
|
||||
<SelectItem value="explorer">Explorer</SelectItem>
|
||||
<SelectItem value="quantum">Quantum</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedModel && (
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-6">
|
||||
<ModelBarChart
|
||||
data={[
|
||||
{ name: 'precision', value: 0.734, fill: 'var(--color-precision)' },
|
||||
{ name: 'recall', value: 0.75, fill: 'var(--color-recall)' },
|
||||
{ name: 'mAP50', value: 0.995, fill: 'var(--color-map50)' },
|
||||
{ name: 'mAP50_95', value: 0.97, fill: 'var(--color-map50-95)' },
|
||||
{ name: 'fitness', value: 0.973, fill: 'var(--color-fitness)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<LabelingPreview />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelingPreview() {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg border bg-white p-4">
|
||||
<p>레이블링 프리뷰</p>
|
||||
</div>
|
||||
);
|
||||
}
|
189
frontend/src/components/ModelManage/SettingsForm.tsx
Normal file
189
frontend/src/components/ModelManage/SettingsForm.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import useProjectModelsQuery from '@/queries/models/useProjectModelsQuery';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SettingsFormProps {
|
||||
projectId: string | null; // projectId를 프랍으로 받음
|
||||
onSubmit?: (data: SettingsFormData) => void;
|
||||
}
|
||||
|
||||
export interface SettingsFormData {
|
||||
projectId: number | null;
|
||||
selectedModel: string | null;
|
||||
ratio: number;
|
||||
epochs: number;
|
||||
batchSize: number;
|
||||
optimizer: string;
|
||||
lr0: number;
|
||||
lrf: number;
|
||||
}
|
||||
|
||||
export default function SettingsForm({ projectId, onSubmit }: SettingsFormProps) {
|
||||
const numericProjectId = projectId ? parseInt(projectId, 10) : null;
|
||||
|
||||
const { data: models } = useProjectModelsQuery(numericProjectId ?? 0);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
const [ratio, setRatio] = useState<number>(0.8);
|
||||
const [epochs, setEpochs] = useState<number>(50);
|
||||
const [batchSize, setBatchSize] = useState<number>(32);
|
||||
const [optimizer, setOptimizer] = useState<string>('SGD');
|
||||
const [lr0, setLr0] = useState<number>(0.01);
|
||||
const [lrf, setLrf] = useState<number>(0.001);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (onSubmit) {
|
||||
onSubmit({
|
||||
projectId: numericProjectId,
|
||||
selectedModel,
|
||||
ratio,
|
||||
epochs,
|
||||
batchSize,
|
||||
optimizer,
|
||||
lr0,
|
||||
lrf,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid w-full gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<fieldset className="grid gap-6 rounded-lg border p-4">
|
||||
<legend className="-ml-1 px-1 text-sm font-medium">모델 설정</legend>
|
||||
|
||||
{/* 모델 선택 */}
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="model">모델 선택</Label>
|
||||
<Select onValueChange={setSelectedModel}>
|
||||
<SelectTrigger id="model">
|
||||
<SelectValue placeholder="모델을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models?.map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 훈련/검증 비율 및 학습 파라미터 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputWithLabel
|
||||
label="훈련/검증 비율"
|
||||
placeholder="예: 0.8 (80% 훈련, 20% 검증)"
|
||||
id="ratio"
|
||||
value={ratio}
|
||||
onChange={(e) => setRatio(parseFloat(e.target.value))}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="에포크 수"
|
||||
placeholder="예: 50 (총 반복 횟수)"
|
||||
id="epochs"
|
||||
value={epochs}
|
||||
onChange={(e) => setEpochs(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Batch 크기"
|
||||
placeholder="예: 32 (한번에 처리할 샘플 수)"
|
||||
id="batch"
|
||||
value={batchSize}
|
||||
onChange={(e) => setBatchSize(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
<SelectWithLabel
|
||||
label="옵티마이저"
|
||||
id="optimizer"
|
||||
options={['SGD', 'Adam', 'AdamW', 'NAdam', 'RAdam', 'RMSProp']}
|
||||
value={optimizer}
|
||||
onChange={setOptimizer}
|
||||
placeholder="옵티마이저 선택"
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="학습률(LR0)"
|
||||
placeholder="예: 0.01 (초기 학습률)"
|
||||
id="lr0"
|
||||
value={lr0}
|
||||
onChange={(e) => setLr0(parseFloat(e.target.value))}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="최종 학습률(LRF)"
|
||||
placeholder="예: 0.001 (최종 학습률)"
|
||||
id="lrf"
|
||||
value={lrf}
|
||||
onChange={(e) => setLrf(parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
>
|
||||
설정 저장
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface InputWithLabelProps {
|
||||
label: string;
|
||||
id: string;
|
||||
placeholder: string;
|
||||
value: number;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function InputWithLabel({ label, id, placeholder, value, onChange }: InputWithLabelProps) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectWithLabelProps {
|
||||
label: string;
|
||||
id: string;
|
||||
options: string[];
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function SelectWithLabel({ label, id, options, placeholder, onChange }: SelectWithLabelProps) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Select onValueChange={onChange}>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
45
frontend/src/components/ModelManage/TrainingTab.tsx
Normal file
45
frontend/src/components/ModelManage/TrainingTab.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import ModelLineChart from '@/components/ModelLineChart';
|
||||
import SettingsForm from './SettingsForm';
|
||||
|
||||
interface TrainingTabProps {
|
||||
training: boolean;
|
||||
handleTrainingToggle: () => void;
|
||||
trainingDataList: {
|
||||
epoch: number;
|
||||
box_loss: number;
|
||||
cls_loss: number;
|
||||
dfl_loss: number;
|
||||
fitness: number;
|
||||
}[];
|
||||
projectId: string | null; // projectId를 프랍으로 받음
|
||||
}
|
||||
|
||||
export default function TrainingTab({ training, handleTrainingToggle, trainingDataList, projectId }: TrainingTabProps) {
|
||||
return (
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsForm projectId={projectId} />
|
||||
<Button
|
||||
variant={training ? 'destructive' : 'outlinePrimary'}
|
||||
size="lg"
|
||||
onClick={handleTrainingToggle}
|
||||
>
|
||||
{training ? '학습 중단' : '학습 시작'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center">
|
||||
<ModelLineChart
|
||||
data={trainingDataList.map((data) => ({
|
||||
epoch: data.epoch.toString(),
|
||||
loss1: data.box_loss,
|
||||
loss2: data.cls_loss,
|
||||
loss3: data.dfl_loss,
|
||||
fitness: data.fitness,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
66
frontend/src/components/ModelManage/index.tsx
Normal file
66
frontend/src/components/ModelManage/index.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import useTrainWebSocket from '@/hooks/useTrainPolling';
|
||||
import useTrainStore from '@/stores/useTrainStore';
|
||||
import TrainingTab from './TrainingTab';
|
||||
import EvaluationTab from './EvaluationTab';
|
||||
|
||||
export default function ModelManage() {
|
||||
const { projectId } = useParams<{ projectId?: string }>();
|
||||
const [training, setTraining] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
|
||||
const numericProjectId = projectId ?? null;
|
||||
|
||||
useTrainWebSocket(training, numericProjectId);
|
||||
|
||||
const { trainingDataList } = useTrainStore((state) => ({
|
||||
trainingDataList: numericProjectId ? state.trainingDataByProject[numericProjectId] || [] : [],
|
||||
}));
|
||||
|
||||
const handleTrainingToggle = () => {
|
||||
setTraining((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-screen w-full">
|
||||
<div className="flex flex-col">
|
||||
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b px-4">
|
||||
<h1 className="text-xl font-semibold">모델 관리</h1>
|
||||
</header>
|
||||
|
||||
<main className="grid flex-1 gap-4 overflow-auto p-4">
|
||||
<Tabs
|
||||
defaultValue="train"
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="train">모델 학습</TabsTrigger>
|
||||
<TabsTrigger value="results">모델 평가</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 학습 탭 */}
|
||||
<TabsContent value="train">
|
||||
<TrainingTab
|
||||
training={training}
|
||||
handleTrainingToggle={handleTrainingToggle}
|
||||
trainingDataList={trainingDataList}
|
||||
projectId={numericProjectId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 평가 탭 */}
|
||||
<TabsContent value="results">
|
||||
<EvaluationTab
|
||||
selectedModel={selectedModel}
|
||||
setSelectedModel={setSelectedModel}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import useHandleOAuthCallback from '@/hooks/useOAuthCallbackHooks';
|
||||
import useHandleOAuthCallback from '@/hooks/useOAuthCallback';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
|
@ -12,17 +12,34 @@ import React from 'react';
|
||||
import ImageUploadFolderForm from '../ImageUploadFolderModal/ImageUploadFolderForm';
|
||||
import ImageUploadZipForm from '../ImageUploadZipModal/ImageUploadZipForm';
|
||||
|
||||
export default function WorkspaceDropdownMenu({ projectId, folderId }: { projectId: number; folderId: number }) {
|
||||
export default function WorkspaceDropdownMenu({
|
||||
projectId,
|
||||
folderId,
|
||||
refetch,
|
||||
}: {
|
||||
projectId: number;
|
||||
folderId: number;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const [isOpenUploadFile, setIsOpenUploadFile] = React.useState(false);
|
||||
const [isOpenUploadFolder, setIsOpenUploadFolder] = React.useState(false);
|
||||
const [isOpenUploadZip, setIsOpenUploadZip] = React.useState(false);
|
||||
|
||||
const handleOpenUploadFile = () => setIsOpenUploadFile(true);
|
||||
const handleCloseUploadFile = () => setIsOpenUploadFile(false);
|
||||
const handleCloseUploadFile = () => {
|
||||
refetch();
|
||||
setIsOpenUploadFile(false);
|
||||
};
|
||||
const handleOpenUploadFolder = () => setIsOpenUploadFolder(true);
|
||||
const handleCloseUploadFolder = () => setIsOpenUploadFolder(false);
|
||||
const handleCloseUploadFolder = () => {
|
||||
refetch();
|
||||
setIsOpenUploadFolder(false);
|
||||
};
|
||||
const handleOpenUploadZip = () => setIsOpenUploadZip(true);
|
||||
const handleCloseUploadZip = () => setIsOpenUploadZip(false);
|
||||
const handleCloseUploadZip = () => {
|
||||
refetch();
|
||||
setIsOpenUploadZip(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -40,7 +57,7 @@ export default function WorkspaceDropdownMenu({ projectId, folderId }: { project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleOpenUploadFile}>파일 업로드</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleOpenUploadFolder}>폴더 업로드</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleOpenUploadFolder}>폴더 업로드 (임시)</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleOpenUploadZip}>폴더 압축파일 업로드</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@ -66,7 +83,7 @@ export default function WorkspaceDropdownMenu({ projectId, folderId }: { project
|
||||
>
|
||||
<DialogTrigger asChild></DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader title="폴더 업로드" />
|
||||
<DialogHeader title="폴더 업로드 (임시)" />
|
||||
<ImageUploadFolderForm
|
||||
onClose={handleCloseUploadFolder}
|
||||
projectId={projectId}
|
||||
|
@ -30,7 +30,7 @@ export default function WorkspaceLayout() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectListData) return;
|
||||
const projects = projectListData.workspaceResponses.map(
|
||||
const projects = projectListData.map(
|
||||
(project): Project => ({
|
||||
id: project.id,
|
||||
name: project.title,
|
||||
|
@ -36,20 +36,19 @@ export default function ProjectDirectoryItem({
|
||||
<button className="flex items-center">
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={`stroke-gray-500 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
className={cn('stroke-gray-500 transition-transform', isExpanded ? 'rotate-90' : '')}
|
||||
/>
|
||||
</button>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{item.title}</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="caption flex flex-col">
|
||||
{
|
||||
<div className={cn('caption flex flex-col', isExpanded ? '' : 'hidden')}>
|
||||
{folderData.children.map((item) => (
|
||||
<ProjectDirectoryItem
|
||||
key={`${projectId}-${item.title}`}
|
||||
projectId={projectId}
|
||||
item={item}
|
||||
depth={depth + 1}
|
||||
initialExpanded={true}
|
||||
/>
|
||||
))}
|
||||
{folderData.images.map((item) => (
|
||||
@ -61,7 +60,7 @@ export default function ProjectDirectoryItem({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import WorkspaceDropdownMenu from '../WorkspaceDropdownMenu';
|
||||
export default function ProjectStructure({ project }: { project: Project }) {
|
||||
const setProject = useCanvasStore((state) => state.setProject);
|
||||
const image = useCanvasStore((state) => state.image);
|
||||
const { data: folderData } = useFolderQuery(project.id.toString(), 0);
|
||||
const { data: folderData, refetch } = useFolderQuery(project.id.toString(), 0);
|
||||
|
||||
useEffect(() => {
|
||||
setProject(project);
|
||||
@ -27,6 +27,7 @@ export default function ProjectStructure({ project }: { project: Project }) {
|
||||
<WorkspaceDropdownMenu
|
||||
projectId={project.id}
|
||||
folderId={0}
|
||||
refetch={refetch}
|
||||
/>
|
||||
</header>
|
||||
{folderData.children.length === 0 && folderData.images.length === 0 ? (
|
||||
|
@ -5,6 +5,7 @@ import { Project } from '@/types';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '../ui/select';
|
||||
import useCanvasStore from '@/stores/useCanvasStore';
|
||||
import { webPath } from '@/router';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function WorkspaceSidebar({ workspaceName, projects }: { workspaceName: string; projects: Project[] }) {
|
||||
const { projectId: selectedProjectId } = useParams<{ projectId: string }>();
|
||||
@ -48,7 +49,7 @@ export default function WorkspaceSidebar({ workspaceName, projects }: { workspac
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedProject && <ProjectStructure project={selectedProject} />}
|
||||
<Suspense fallback={<div></div>}>{selectedProject && <ProjectStructure project={selectedProject} />}</Suspense>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="bg-gray-300" />
|
||||
</>
|
||||
|
@ -1,39 +0,0 @@
|
||||
// import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
// import useAuthStore from '@/stores/useAuthStore';
|
||||
// import { reissueToken } from '@/api/authApi';
|
||||
// import { useEffect } from 'react';
|
||||
|
||||
// import useProfileQuery from '@/queries/auth/useProfileQuery';
|
||||
|
||||
// export const useReissueToken = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
// const { setLoggedIn } = useAuthStore();
|
||||
|
||||
// return useMutation({
|
||||
// mutationFn: reissueToken,
|
||||
// onSuccess: (data) => {
|
||||
// setLoggedIn(true, data.accessToken);
|
||||
// queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useProfile = () => {
|
||||
// const { setProfile } = useAuthStore();
|
||||
// const query = useProfileQuery();
|
||||
|
||||
// // TODO: query.data가 변경될 때마다 setProfile을 호출하여 profile 업데이트, useEffect 제거
|
||||
// useEffect(() => {
|
||||
// setProfile(query.data);
|
||||
// }, [query.data, setProfile]);
|
||||
|
||||
// return query;
|
||||
// };
|
||||
|
||||
// export const useFetchProfile = () => {
|
||||
// const { setProfile } = useAuthStore();
|
||||
// const query = useProfileQuery();
|
||||
// if (query.data) {
|
||||
// setProfile(query.data);
|
||||
// }
|
||||
// };
|
17
frontend/src/hooks/useIsAdmin.ts
Normal file
17
frontend/src/hooks/useIsAdmin.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import useProjectMembersQuery from '@/queries/projects/useProjectMembersQuery';
|
||||
|
||||
export default function useIsAdmin(projectId: number) {
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
const memberId = profile?.id || 0;
|
||||
|
||||
const { data: projectMembers = [] } = useProjectMembersQuery(projectId, memberId);
|
||||
|
||||
const isAdminOrManager = useMemo(() => {
|
||||
const currentMember = projectMembers.find((member) => member.memberId === memberId);
|
||||
return currentMember?.privilegeType === 'ADMIN';
|
||||
}, [projectMembers, memberId]);
|
||||
|
||||
return isAdminOrManager;
|
||||
}
|
@ -1,198 +0,0 @@
|
||||
// TODO: 훅 재설계
|
||||
// import { useQuery, UseQueryResult, useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query';
|
||||
// import { AxiosError } from 'axios';
|
||||
// import {
|
||||
// getProject,
|
||||
// updateProject,
|
||||
// deleteProject,
|
||||
// getAllProjects,
|
||||
// createProject,
|
||||
// addProjectMember,
|
||||
// removeProjectMember,
|
||||
// } from '@/api/projectApi';
|
||||
// import { BaseResponse, ProjectResponse, ProjectListResponse, CustomError } from '@/types';
|
||||
|
||||
// export const useGetProject = (
|
||||
// projectId: number,
|
||||
// memberId: number
|
||||
// ): UseQueryResult<BaseResponse<ProjectResponse>, AxiosError<CustomError>> => {
|
||||
// return useQuery<BaseResponse<ProjectResponse>, AxiosError<CustomError>>({
|
||||
// queryKey: ['project', projectId],
|
||||
// queryFn: () => getProject(projectId, memberId),
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useUpdateProject = (): UseMutationResult<
|
||||
// BaseResponse<ProjectResponse>,
|
||||
// AxiosError<CustomError>,
|
||||
// {
|
||||
// projectId: number;
|
||||
// memberId: number;
|
||||
// data: { title: string; projectType: 'classification' | 'detection' | 'segmentation' };
|
||||
// }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ projectId, memberId, data }) => updateProject(projectId, memberId, data),
|
||||
// onSuccess: (data) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['project', data.data.id] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useDeleteProject = (): UseMutationResult<
|
||||
// BaseResponse<null>,
|
||||
// AxiosError<CustomError>,
|
||||
// { projectId: number; memberId: number }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ projectId, memberId }) => deleteProject(projectId, memberId),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useGetAllProjects = (
|
||||
// workspaceId: number,
|
||||
// memberId: number,
|
||||
// options?: { enabled: boolean }
|
||||
// ): UseQueryResult<BaseResponse<ProjectListResponse>, AxiosError<CustomError>> => {
|
||||
// return useQuery<BaseResponse<ProjectListResponse>, AxiosError<CustomError>>({
|
||||
// queryKey: ['projects', workspaceId],
|
||||
// queryFn: () => getAllProjects(workspaceId, memberId),
|
||||
// enabled: options?.enabled,
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useCreateProject = (): UseMutationResult<
|
||||
// BaseResponse<ProjectResponse>,
|
||||
// AxiosError<CustomError>,
|
||||
// {
|
||||
// workspaceId: number;
|
||||
// memberId: number;
|
||||
// data: { title: string; projectType: 'classification' | 'detection' | 'segmentation' };
|
||||
// }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ workspaceId, memberId, data }) => createProject(workspaceId, memberId, data),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['projects', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useAddProjectMember = (): UseMutationResult<
|
||||
// BaseResponse<null>,
|
||||
// AxiosError<CustomError>,
|
||||
// { projectId: number; memberId: number; newMemberId: number; privilegeType: string }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ projectId, memberId, newMemberId, privilegeType }) =>
|
||||
// addProjectMember(projectId, memberId, newMemberId, privilegeType),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useRemoveProjectMember = (): UseMutationResult<
|
||||
// BaseResponse<null>,
|
||||
// AxiosError<CustomError>,
|
||||
// { projectId: number; memberId: number; targetMemberId: number }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ projectId, memberId, targetMemberId }) => removeProjectMember(projectId, memberId, targetMemberId),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
// import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
// import {
|
||||
// createProject,
|
||||
// updateProject,
|
||||
// deleteProject,
|
||||
// addProjectMember,
|
||||
// updateProjectMemberPrivilege,
|
||||
// removeProjectMember,
|
||||
// } from '@/api/projectApi';
|
||||
// import { ProjectResponse, ProjectRequest, ProjectMemberRequest, ProjectMemberResponse } from '@/types';
|
||||
|
||||
// export const useCreateProject = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
|
||||
// return useMutation<ProjectResponse, Error, { workspaceId: number; memberId: number; data: ProjectRequest }>({
|
||||
// mutationFn: ({ workspaceId, memberId, data }) => createProject(workspaceId, memberId, data),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['projects', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useUpdateProject = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
|
||||
// return useMutation<ProjectResponse, Error, { projectId: number; memberId: number; data: ProjectRequest }>({
|
||||
// mutationFn: ({ projectId, memberId, data }) => updateProject(projectId, memberId, data),
|
||||
// onSuccess: (data) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['project', data.id] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useDeleteProject = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
|
||||
// return useMutation<void, Error, { projectId: number; memberId: number }>({
|
||||
// mutationFn: ({ projectId, memberId }) => deleteProject(projectId, memberId),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['projects', variables.projectId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useAddProjectMember = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation<
|
||||
// ProjectMemberResponse,
|
||||
// Error,
|
||||
// { projectId: number; memberId: number; newMember: ProjectMemberRequest }
|
||||
// >({
|
||||
// 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 = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation<void, Error, { projectId: number; memberId: number; targetMemberId: number }>({
|
||||
// mutationFn: ({ projectId, memberId, targetMemberId }) => removeProjectMember(projectId, memberId, targetMemberId),
|
||||
// onSuccess: (_, { projectId }) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['projectMembers', projectId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
@ -1,57 +0,0 @@
|
||||
// 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] });
|
||||
// },
|
||||
// });
|
||||
// };
|
59
frontend/src/hooks/useReviewRequest.ts
Normal file
59
frontend/src/hooks/useReviewRequest.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import useProjectListQuery from '@/queries/projects/useProjectListQuery';
|
||||
import useCreateReviewQuery from '@/queries/reviews/useCreateReviewQuery';
|
||||
import type { ReviewRequest } from '@/types';
|
||||
|
||||
export default function useReviewRequest() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [selectedImages, setSelectedImages] = useState<number[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
const memberId = profile?.id || 0;
|
||||
|
||||
const { data: projects } = useProjectListQuery(Number(workspaceId), memberId);
|
||||
const createReview = useCreateReviewQuery();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ReviewRequest>();
|
||||
|
||||
const onSubmit = (data: ReviewRequest) => {
|
||||
if (!selectedProjectId) {
|
||||
return;
|
||||
}
|
||||
createReview.mutate(
|
||||
{
|
||||
projectId: Number(selectedProjectId),
|
||||
memberId,
|
||||
reviewData: {
|
||||
...data,
|
||||
imageIds: selectedImages,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate(`/admin/${workspaceId}/reviews`);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
errors,
|
||||
projects,
|
||||
onSubmit,
|
||||
selectedProjectId,
|
||||
setSelectedProjectId,
|
||||
selectedImages,
|
||||
setSelectedImages,
|
||||
};
|
||||
}
|
49
frontend/src/hooks/useTrainPolling.ts
Normal file
49
frontend/src/hooks/useTrainPolling.ts
Normal file
@ -0,0 +1,49 @@
|
||||
// 임시 가짜 훅
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import useTrainStore from '@/stores/useTrainStore';
|
||||
|
||||
export default function useTrainPolling(start: boolean, projectId?: string | null) {
|
||||
const { addTrainingData, resetTrainingData } = useTrainStore((state) => ({
|
||||
addTrainingData: state.addTrainingData,
|
||||
resetTrainingData: state.resetTrainingData,
|
||||
}));
|
||||
|
||||
const intervalIdRef = useRef<number | null>(null);
|
||||
// 함수 api 후 교체 예정
|
||||
const fetchTrainingData = useCallback(async () => {
|
||||
if (projectId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/바보=${projectId}`);
|
||||
const data = response.data;
|
||||
|
||||
addTrainingData(projectId, {
|
||||
epoch: data.epoch,
|
||||
total_epochs: data.total_epochs,
|
||||
box_loss: data.box_loss,
|
||||
cls_loss: data.cls_loss,
|
||||
dfl_loss: data.dfl_loss,
|
||||
fitness: data.fitness,
|
||||
epoch_time: data.epoch_time,
|
||||
left_second: data.left_second,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fetching error:', error);
|
||||
}
|
||||
}
|
||||
}, [projectId, addTrainingData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (start && projectId) {
|
||||
resetTrainingData(projectId);
|
||||
intervalIdRef.current = window.setInterval(fetchTrainingData, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalIdRef.current) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
intervalIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [start, projectId, fetchTrainingData, resetTrainingData]);
|
||||
}
|
@ -1,155 +0,0 @@
|
||||
// TODO: 훅 재설계
|
||||
// import { useQuery } from '@tanstack/react-query';
|
||||
// import { getWorkspace, getWorkspaceList } from '@/api/workspaceApi';
|
||||
|
||||
// export const useGetWorkspace = (workspaceId: number, memberId: number) => {
|
||||
// return useQuery({
|
||||
// queryKey: ['workspace', workspaceId],
|
||||
// queryFn: () => getWorkspace(workspaceId, memberId),
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useGetWorkspaceList = (memberId: number, lastWorkspaceId?: number, limit?: number) => {
|
||||
// return useQuery({
|
||||
// queryKey: ['workspaces'],
|
||||
// queryFn: () => getWorkspaceList(memberId, lastWorkspaceId, limit),
|
||||
// });
|
||||
// };
|
||||
|
||||
// TODO: 수정된 쿼리에 맞게 훅 수정
|
||||
// export const useUpdateWorkspace = (): UseMutationResult<
|
||||
// BaseResponse<WorkspaceResponse>,
|
||||
// AxiosError<CustomError>,
|
||||
// { workspaceId: number; memberId: number; data: { title: string; content: string } }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ workspaceId, memberId, data }) => updateWorkspace(workspaceId, memberId, data),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useDeleteWorkspace = (): UseMutationResult<
|
||||
// BaseResponse<null>,
|
||||
// AxiosError<CustomError>,
|
||||
// { workspaceId: number; memberId: number }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ workspaceId, memberId }) => deleteWorkspace(workspaceId, memberId),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useCreateWorkspace = (): UseMutationResult<
|
||||
// BaseResponse<WorkspaceResponse>,
|
||||
// AxiosError<CustomError>,
|
||||
// { memberId: number; data: { title: string; content: string } }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ memberId, data }) => createWorkspace(memberId, data),
|
||||
// onSuccess: () => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useAddWorkspaceMember = (): UseMutationResult<
|
||||
// BaseResponse<null>,
|
||||
// AxiosError<CustomError>,
|
||||
// { workspaceId: number; memberId: number; newMemberId: number }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ workspaceId, memberId, newMemberId }) => addWorkspaceMember(workspaceId, memberId, newMemberId),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useRemoveWorkspaceMember = (): UseMutationResult<
|
||||
// BaseResponse<null>,
|
||||
// AxiosError<CustomError>,
|
||||
// { workspaceId: number; memberId: number; targetMemberId: number }
|
||||
// > => {
|
||||
// const queryClient = useQueryClient();
|
||||
// return useMutation({
|
||||
// mutationFn: ({ workspaceId, memberId, targetMemberId }) =>
|
||||
// removeWorkspaceMember(workspaceId, memberId, targetMemberId),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
// import {
|
||||
// createWorkspace,
|
||||
// updateWorkspace,
|
||||
// deleteWorkspace,
|
||||
// addWorkspaceMember,
|
||||
// removeWorkspaceMember,
|
||||
// } from '@/api/workspaceApi';
|
||||
// import { WorkspaceResponse, WorkspaceRequest } from '@/types';
|
||||
|
||||
// export const useCreateWorkspace = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
|
||||
// return useMutation<WorkspaceResponse, Error, { memberId: number; data: WorkspaceRequest }>({
|
||||
// mutationFn: ({ memberId, data }) => createWorkspace(memberId, data),
|
||||
// onSuccess: () => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspaceList'] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useUpdateWorkspace = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
|
||||
// return useMutation<WorkspaceResponse, Error, { workspaceId: number; memberId: number; data: WorkspaceRequest }>({
|
||||
// mutationFn: ({ workspaceId, memberId, data }) => updateWorkspace(workspaceId, memberId, data),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useDeleteWorkspace = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
|
||||
// return useMutation<void, Error, { workspaceId: number; memberId: number }>({
|
||||
// mutationFn: ({ workspaceId, memberId }) => deleteWorkspace(workspaceId, memberId),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useAddWorkspaceMember = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
|
||||
// return useMutation<void, Error, { workspaceId: number; memberId: number; newMemberId: number }>({
|
||||
// mutationFn: ({ workspaceId, memberId, newMemberId }) => addWorkspaceMember(workspaceId, memberId, newMemberId),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// export const useRemoveWorkspaceMember = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
|
||||
// return useMutation<void, Error, { workspaceId: number; memberId: number; targetMemberId: number }>({
|
||||
// mutationFn: ({ workspaceId, memberId, targetMemberId }) =>
|
||||
// removeWorkspaceMember(workspaceId, memberId, targetMemberId),
|
||||
// onSuccess: (_, variables) => {
|
||||
// queryClient.invalidateQueries({ queryKey: ['workspace', variables.workspaceId] });
|
||||
// },
|
||||
// });
|
||||
// };
|
@ -7,7 +7,6 @@ import {
|
||||
MemberResponse,
|
||||
RefreshTokenResponse,
|
||||
AutoLabelingResponse,
|
||||
ProjectListResponse,
|
||||
ErrorResponse,
|
||||
} from '@/types';
|
||||
|
||||
@ -100,15 +99,15 @@ export const handlers = [
|
||||
| 'segmentation',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
thumbnail: `thumbnail_${lastProjectId + index + 1}.jpg`,
|
||||
}));
|
||||
|
||||
// 응답 생성
|
||||
const response: ProjectListResponse = {
|
||||
workspaceResponses: projects,
|
||||
};
|
||||
const response: ProjectResponse[] = projects;
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
http.get('/api/projects/:projectId', ({ params }) => {
|
||||
// 프로젝트 조회 핸들러
|
||||
const { projectId } = params;
|
||||
|
@ -1,16 +0,0 @@
|
||||
import WorkspaceDropdownMenu from '@/components/WorkspaceDropdownMenu';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export default function ImageFolderUploadTest() {
|
||||
const params = useParams<{ workspaceId: string; projectId: string }>();
|
||||
const projectId = Number(params.projectId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full">
|
||||
<WorkspaceDropdownMenu
|
||||
projectId={projectId}
|
||||
folderId={0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
12
frontend/src/pages/ModelDetail.tsx
Normal file
12
frontend/src/pages/ModelDetail.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Suspense } from 'react';
|
||||
import ModelManage from '@/components/ModelManage';
|
||||
|
||||
export default function ModelDetail() {
|
||||
return (
|
||||
<Suspense fallback={<div></div>}>
|
||||
<div className="flex h-full w-full justify-center">
|
||||
<ModelManage />
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
13
frontend/src/pages/ModelIndex.tsx
Normal file
13
frontend/src/pages/ModelIndex.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Smile } from 'lucide-react';
|
||||
|
||||
export default function ModelIndex() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<Smile
|
||||
size={48}
|
||||
className="mb-2 text-gray-300"
|
||||
/>
|
||||
<div className="body-strong text-gray-400">작업할 프로젝트를 선택하세요.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,268 +0,0 @@
|
||||
import { Rabbit, Bird, Turtle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import ModelLineChart from '@/components/ModelLineChart';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useState } from 'react';
|
||||
import ModelBarChart from '@/components/ModelBarChart';
|
||||
|
||||
export default function ModelManage() {
|
||||
interface MetricData {
|
||||
epoch: string;
|
||||
loss1: number;
|
||||
loss2: number;
|
||||
loss3: number;
|
||||
fitness: number;
|
||||
}
|
||||
|
||||
const dummyLossData: MetricData[] = [
|
||||
{ epoch: '1', loss1: 0.45, loss2: 0.43, loss3: 0.42, fitness: 0.97 },
|
||||
{ epoch: '2', loss1: 0.4, loss2: 0.38, loss3: 0.37, fitness: 0.98 },
|
||||
{ epoch: '3', loss1: 0.38, loss2: 0.36, loss3: 0.35, fitness: 0.99 },
|
||||
{ epoch: '4', loss1: 0.36, loss2: 0.34, loss3: 0.33, fitness: 1.0 },
|
||||
];
|
||||
|
||||
const [lossData] = useState<MetricData[]>(dummyLossData);
|
||||
const [training, setTraining] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
|
||||
const handleTrainingToggle = () => {
|
||||
setTraining((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-screen w-full pl-[56px]">
|
||||
<div className="flex flex-col">
|
||||
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b px-4">
|
||||
<h1 className="text-xl font-semibold">모델 관리</h1>
|
||||
</header>
|
||||
|
||||
<main className="grid flex-1 gap-4 overflow-auto p-4">
|
||||
<Tabs
|
||||
defaultValue="train"
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="train">모델 학습</TabsTrigger>
|
||||
<TabsTrigger value="results">모델 평가</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 모델 학습 탭 */}
|
||||
<TabsContent value="train">
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsForm />
|
||||
<Button
|
||||
variant={training ? 'destructive' : 'outlinePrimary'}
|
||||
size="lg"
|
||||
onClick={handleTrainingToggle}
|
||||
>
|
||||
{training ? '학습 중단' : '학습 시작'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<ModelLineChart data={lossData} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 모델 평가 탭 */}
|
||||
<TabsContent value="results">
|
||||
{/* 모델 선택 */}
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="select-model">모델 선택</Label>
|
||||
<Select onValueChange={setSelectedModel}>
|
||||
<SelectTrigger id="select-model">
|
||||
<SelectValue placeholder="모델을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="genesis">Genesis</SelectItem>
|
||||
<SelectItem value="explorer">Explorer</SelectItem>
|
||||
<SelectItem value="quantum">Quantum</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 선택된 모델에 따른 BarChart 및 Labeling Preview */}
|
||||
{selectedModel && (
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-6">
|
||||
<ModelBarChart
|
||||
data={[
|
||||
{ name: 'precision', value: 0.734, fill: 'var(--color-precision)' },
|
||||
{ name: 'recall', value: 0.75, fill: 'var(--color-recall)' },
|
||||
{ name: 'mAP50', value: 0.995, fill: 'var(--color-map50)' },
|
||||
{ name: 'mAP50_95', value: 0.97, fill: 'var(--color-map50-95)' },
|
||||
{ name: 'fitness', value: 0.973, fill: 'var(--color-fitness)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<LabelingPreview />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelingPreview() {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg border bg-white p-4">
|
||||
<p>레이블링 프리뷰</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsForm() {
|
||||
return (
|
||||
<form className="grid w-full gap-6">
|
||||
<fieldset className="grid gap-6 rounded-lg border p-4">
|
||||
<legend className="-ml-1 px-1 text-sm font-medium">모델 설정</legend>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="model">모델 선택</Label>
|
||||
<Select>
|
||||
<SelectTrigger id="model">
|
||||
<SelectValue placeholder="모델을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="genesis">
|
||||
<OptionWithIcon
|
||||
icon={<Rabbit />}
|
||||
title="Genesis"
|
||||
description="일반 사용 사례를 위한 빠른 모델"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="explorer">
|
||||
<OptionWithIcon
|
||||
icon={<Bird />}
|
||||
title="Explorer"
|
||||
description="효율성을 위한 빠른 모델"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="quantum">
|
||||
<OptionWithIcon
|
||||
icon={<Turtle />}
|
||||
title="Quantum"
|
||||
description="복잡한 계산을 위한 강력한 모델"
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputWithLabel
|
||||
label="훈련/검증 비율"
|
||||
placeholder="0.8"
|
||||
id="ratio"
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="에포크 수"
|
||||
placeholder="50"
|
||||
id="epochs"
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Batch"
|
||||
placeholder="-1"
|
||||
id="batch"
|
||||
/>
|
||||
<SelectWithLabel
|
||||
label="옵티마이저"
|
||||
id="optimizer"
|
||||
options={['SGD', 'Adam', 'AdamW', 'NAdam', 'RAdam', 'RMSProp']}
|
||||
placeholder="옵티마이저 선택"
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="학습률(LR0)"
|
||||
placeholder="0.01"
|
||||
id="lr0"
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="학습률(LRF)"
|
||||
placeholder="0.01"
|
||||
id="lrf"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionWithIconProps {
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function OptionWithIcon({ icon, title, description }: OptionWithIconProps) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-start gap-3">
|
||||
{icon}
|
||||
<div className="grid gap-0.5">
|
||||
<p>
|
||||
Neural <span className="text-foreground font-medium">{title}</span>
|
||||
</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
data-description
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InputWithLabelProps {
|
||||
label: string;
|
||||
id: string;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
function InputWithLabel({ label, id, placeholder }: InputWithLabelProps) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface SelectWithLabelProps {
|
||||
label: string;
|
||||
id: string;
|
||||
options: string[];
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
function SelectWithLabel({ label, id, options, placeholder }: SelectWithLabelProps) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Select>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -9,6 +9,8 @@ import useAddProjectMemberQuery from '@/queries/projects/useAddProjectMemberQuer
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import MemberAddModal from '@/components/MemberAddModal';
|
||||
import useIsAdminOrManager from '@/hooks/useIsAdminOrManager';
|
||||
|
||||
type Role = 'ADMIN' | 'MANAGER' | 'EDITOR' | 'VIEWER' | 'NONE';
|
||||
const roles: Role[] = ['ADMIN', 'MANAGER', 'EDITOR', 'VIEWER', 'NONE'];
|
||||
const roleToStr: { [key in Role]: string } = {
|
||||
@ -25,20 +27,21 @@ export default function ProjectMemberManage() {
|
||||
const memberId = profile?.id || 0;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const previousProjectId = useRef(projectId);
|
||||
|
||||
const { data: projectMembers = [] } = useProjectMembersQuery(Number(projectId), memberId);
|
||||
const { data: workspaceMembers = [] } = useWorkspaceMembersQuery(Number(workspaceId));
|
||||
|
||||
const isAdminOrManager = useIsAdminOrManager(Number(projectId));
|
||||
|
||||
const updatePrivilege = useUpdateProjectMemberPrivilegeQuery();
|
||||
const removeMember = useRemoveProjectMemberQuery();
|
||||
const addProjectMember = useAddProjectMemberQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && previousProjectId.current !== projectId) {
|
||||
queryClient.invalidateQueries({ queryKey: ['projectMembers', Number(previousProjectId.current), memberId] }); // 이전 projectId의 캐시 무효화
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', Number(workspaceId)] }); // workspaceMembers 무효화
|
||||
queryClient.invalidateQueries({ queryKey: ['projectMembers', Number(previousProjectId.current), memberId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', Number(workspaceId)] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projectMembers', Number(projectId), memberId] });
|
||||
|
||||
previousProjectId.current = projectId;
|
||||
@ -85,43 +88,57 @@ export default function ProjectMemberManage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={projectId}
|
||||
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]">프로젝트 멤버 관리</h1>
|
||||
<MemberAddModal projectId={projectId ? Number(projectId) : 0} />
|
||||
</header>
|
||||
<div className="grid h-screen w-full">
|
||||
<div className="flex flex-col">
|
||||
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-4 border-b px-4">
|
||||
<h1 className="flex-1 text-xl font-semibold">프로젝트 멤버 관리</h1>
|
||||
{isAdminOrManager && <MemberAddModal projectId={projectId ? Number(projectId) : 0} />}
|
||||
</header>
|
||||
|
||||
{sortedMembers.map((member) => (
|
||||
<div
|
||||
key={`${member.memberId}-${member.nickname}`}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<span className="flex-1">{member.nickname}</span>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
onValueChange={(value) => handleRoleChange(member.memberId, value as Role)}
|
||||
defaultValue={member.privilegeType}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="역할을 선택해주세요." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={role}
|
||||
value={role}
|
||||
>
|
||||
{roleToStr[role]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<main className="grid flex-1 gap-4 overflow-auto p-4">
|
||||
{sortedMembers.length === 0 ? (
|
||||
<div className="py-4 text-center">프로젝트에 멤버가 없습니다.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedMembers.map((member) => (
|
||||
<div
|
||||
key={`${member.memberId}-${member.nickname}`}
|
||||
className="flex items-center gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<img
|
||||
src={member.profileImage}
|
||||
alt={member.nickname}
|
||||
className="h-12 w-12 rounded-full"
|
||||
/>
|
||||
<span className="flex-1 text-lg font-medium">{member.nickname}</span>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
onValueChange={(value) => handleRoleChange(member.memberId, value as Role)}
|
||||
defaultValue={member.privilegeType}
|
||||
disabled={!isAdminOrManager || member.privilegeType === 'ADMIN'}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="역할을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={role}
|
||||
value={role}
|
||||
disabled={role === 'ADMIN'}
|
||||
>
|
||||
{roleToStr[role]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ export default function ProjectReviewList() {
|
||||
to={`/admin/${workspaceId}/reviews/request`}
|
||||
className="ml-auto"
|
||||
>
|
||||
<Button variant="default">리뷰 요청</Button>
|
||||
<Button variant="outlinePrimary">리뷰 요청</Button>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
|
@ -1,55 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import useCreateReviewQuery from '@/queries/reviews/useCreateReviewQuery';
|
||||
import type { ReviewRequest } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import useProjectListQuery from '@/queries/projects/useProjectListQuery';
|
||||
import ImageSelection from '@/components/ImageSelection';
|
||||
import useReviewRequest from '@/hooks/useReviewRequest';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function ReviewRequest(): JSX.Element {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [selectedImages, setSelectedImages] = useState<number[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
const memberId = profile?.id || 0;
|
||||
|
||||
const { data: projectList } = useProjectListQuery(Number(workspaceId), memberId);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ReviewRequest>();
|
||||
const createReview = useCreateReviewQuery();
|
||||
|
||||
const onSubmit = (data: ReviewRequest) => {
|
||||
if (!selectedProjectId) {
|
||||
return;
|
||||
}
|
||||
createReview.mutate(
|
||||
{
|
||||
projectId: Number(selectedProjectId),
|
||||
memberId,
|
||||
reviewData: {
|
||||
...data,
|
||||
imageIds: selectedImages,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate(`/admin/${workspaceId}/reviews`);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
errors,
|
||||
projects,
|
||||
onSubmit,
|
||||
selectedProjectId,
|
||||
setSelectedProjectId,
|
||||
selectedImages,
|
||||
setSelectedImages,
|
||||
} = useReviewRequest();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="review-request-container p-4">
|
||||
@ -63,8 +33,8 @@ export default function ReviewRequest(): JSX.Element {
|
||||
<SelectValue placeholder="프로젝트를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectList?.workspaceResponses.length ? (
|
||||
projectList.workspaceResponses.map((project) => (
|
||||
{projects.length ? (
|
||||
projects.map((project) => (
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
value={project.id.toString()}
|
||||
|
@ -16,7 +16,7 @@ export default function WorkspaceBrowseDetail() {
|
||||
const memberId = profile?.id ?? 0;
|
||||
|
||||
const { data: workspaceData } = useWorkspaceQuery(workspaceId, memberId);
|
||||
const { data: projectsResponse, isError } = useProjectListQuery(workspaceId, memberId);
|
||||
const { data: projects, isError } = useProjectListQuery(workspaceId, memberId);
|
||||
|
||||
const createProject = useCreateProjectQuery();
|
||||
|
||||
@ -28,8 +28,6 @@ export default function WorkspaceBrowseDetail() {
|
||||
});
|
||||
};
|
||||
|
||||
const projects: ProjectResponse[] = projectsResponse?.workspaceResponses ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-8 px-6 py-4">
|
||||
<HeaderSection
|
||||
@ -105,6 +103,7 @@ function ProjectList({ projects, workspaceId }: { projects: ProjectResponse[]; w
|
||||
title={project.title}
|
||||
to={`${webPath.workspace()}/${workspaceId}/${project.id}`}
|
||||
description={project.projectType}
|
||||
imageUrl={project.thumbnail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -11,32 +11,39 @@ export default function WorkspaceMemberManage() {
|
||||
const { data: members = [] } = useWorkspaceMembersQuery(Number(workspaceId));
|
||||
|
||||
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]">워크스페이스 멤버 관리</h1>
|
||||
<WorkspaceMemberAddModal
|
||||
workspaceId={Number(workspaceId)}
|
||||
memberId={memberId}
|
||||
/>
|
||||
</header>
|
||||
<div className="grid h-screen w-full">
|
||||
<div className="flex flex-col">
|
||||
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-4 border-b px-4">
|
||||
<h1 className="flex-1 text-xl font-semibold">워크스페이스 멤버 관리</h1>
|
||||
|
||||
{members.length === 0 ? (
|
||||
<div className="py-4 text-center">워크스페이스에 멤버가 없습니다.</div>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-4 border-b pb-2"
|
||||
>
|
||||
<img
|
||||
src={member.profileImage}
|
||||
alt={member.nickname}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<span>{member.nickname}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<WorkspaceMemberAddModal
|
||||
workspaceId={Number(workspaceId)}
|
||||
memberId={memberId}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto p-4">
|
||||
{members.length === 0 ? (
|
||||
<div className="py-4 text-center">워크스페이스에 멤버가 없습니다.</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex w-full items-center gap-4 rounded-lg border p-2 md:w-1/2 lg:w-2/5"
|
||||
>
|
||||
<img
|
||||
src={member.profileImage}
|
||||
alt={member.nickname}
|
||||
className="h-12 w-12 rounded-full"
|
||||
/>
|
||||
<span className="flex-1 text-sm font-medium">{member.nickname}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ export default function WorkspaceReviewList() {
|
||||
to={`/admin/${workspaceId}/reviews/request`}
|
||||
className="ml-auto"
|
||||
>
|
||||
<Button variant="default">리뷰 요청</Button>
|
||||
<Button variant="outlinePrimary">리뷰 요청</Button>
|
||||
</Link>
|
||||
</header>
|
||||
<ReviewList
|
||||
|
@ -16,17 +16,17 @@ import WorkspaceBrowseIndex from '@/pages/WorkspaceBrowseIndex';
|
||||
import AdminIndex from '@/pages/AdminIndex';
|
||||
import LabelCanvas from '@/pages/LabelCanvas';
|
||||
import ReviewDetail from '@/pages/ReviewDetail';
|
||||
import ImageFolderUploadTest from '@/pages/ImageFolderUploadTest';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
import ModelManage from '@/pages/ModelManage';
|
||||
import ReviewRequest from '@/pages/ReviewRequest';
|
||||
import ModelIndex from '@/pages/ModelIndex';
|
||||
import ModelDetail from '@/pages/ModelDetail';
|
||||
|
||||
export const webPath = {
|
||||
home: () => '/',
|
||||
browse: () => '/browse',
|
||||
workspace: () => '/workspace',
|
||||
admin: () => `/admin`,
|
||||
oauthCallback: () => '/redirect/oauth2',
|
||||
imageFolderUploadTest: () => '/imagefolderuploadtest',
|
||||
};
|
||||
|
||||
const router = createBrowserRouter([
|
||||
@ -133,7 +133,11 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <ModelManage />,
|
||||
element: <ModelIndex />,
|
||||
},
|
||||
{
|
||||
path: ':projectId',
|
||||
element: <ModelDetail />,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -147,10 +151,6 @@ const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `${webPath.imageFolderUploadTest()}/:projectId`,
|
||||
element: <ImageFolderUploadTest />,
|
||||
},
|
||||
]);
|
||||
|
||||
export default router;
|
||||
|
40
frontend/src/stores/useTrainStore.ts
Normal file
40
frontend/src/stores/useTrainStore.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface TrainingData {
|
||||
epoch: number;
|
||||
total_epochs: number;
|
||||
box_loss: number;
|
||||
cls_loss: number;
|
||||
dfl_loss: number;
|
||||
fitness: number;
|
||||
epoch_time: number;
|
||||
left_second: number;
|
||||
}
|
||||
|
||||
interface StoreState {
|
||||
trainingDataByProject: { [projectId: string]: TrainingData[] };
|
||||
addTrainingData: (projectId: string, data: TrainingData) => void;
|
||||
resetTrainingData: (projectId: string) => void;
|
||||
}
|
||||
|
||||
const useTrainStore = create<StoreState>((set) => ({
|
||||
trainingDataByProject: {},
|
||||
|
||||
addTrainingData: (projectId: string, data: TrainingData) =>
|
||||
set((state) => ({
|
||||
trainingDataByProject: {
|
||||
...state.trainingDataByProject,
|
||||
[projectId]: [...(state.trainingDataByProject[projectId] || []), data],
|
||||
},
|
||||
})),
|
||||
|
||||
resetTrainingData: (projectId: string) =>
|
||||
set((state) => ({
|
||||
trainingDataByProject: {
|
||||
...state.trainingDataByProject,
|
||||
[projectId]: [],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useTrainStore;
|
@ -122,18 +122,15 @@ export interface ProjectRequest {
|
||||
projectType: 'classification' | 'detection' | 'segmentation';
|
||||
}
|
||||
|
||||
export interface ProjectResponse {
|
||||
export type ProjectResponse = {
|
||||
id: number;
|
||||
title: string;
|
||||
workspaceId: number;
|
||||
projectType: 'classification' | 'detection' | 'segmentation';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectListResponse {
|
||||
workspaceResponses: ProjectResponse[];
|
||||
}
|
||||
thumbnail?: string; // Optional
|
||||
};
|
||||
|
||||
// 댓글 관련 DTO
|
||||
export interface CommentRequest {
|
||||
|
Loading…
Reference in New Issue
Block a user