Refactor: 리뷰 제출
This commit is contained in:
parent
b7019dbf28
commit
101ceae953
@ -1,5 +1,6 @@
|
||||
// ImageSelection.tsx
|
||||
import { Label } from '@/components/ui/label';
|
||||
import useFolderQuery from '@/queries/folders/useFolderQuery';
|
||||
import useRecursiveSavedImages from '@/hooks/useRecursiveSavedImages';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
@ -10,13 +11,18 @@ interface ImageSelectionProps {
|
||||
}
|
||||
|
||||
export default function ImageSelection({ projectId, selectedImages, setSelectedImages }: ImageSelectionProps) {
|
||||
const { data: folderData } = useFolderQuery(projectId, 0);
|
||||
const savedImages = folderData?.images.filter((image) => image.status === 'SAVE') || [];
|
||||
const { allSavedImages } = useRecursiveSavedImages(projectId, 0);
|
||||
|
||||
const handleImageSelect = (imageId: number) => {
|
||||
setSelectedImages((prev: number[]) =>
|
||||
prev.includes(imageId) ? prev.filter((id) => id !== imageId) : [...prev, imageId]
|
||||
);
|
||||
// 상태 업데이트 안전하게 관리
|
||||
setSelectedImages((prevSelectedImages) => {
|
||||
// 이미 선택된 이미지가 있는 경우 필터링하여 제거
|
||||
if (prevSelectedImages.includes(imageId)) {
|
||||
return prevSelectedImages.filter((id) => id !== imageId);
|
||||
}
|
||||
// 선택되지 않은 이미지를 배열에 추가
|
||||
return [...prevSelectedImages, imageId];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@ -24,8 +30,8 @@ export default function ImageSelection({ projectId, selectedImages, setSelectedI
|
||||
<Label>이미지 선택 (파일 목록)</Label>
|
||||
<ScrollArea className="max-h-64 overflow-auto border p-2">
|
||||
<ul className="space-y-2">
|
||||
{savedImages.length > 0 ? (
|
||||
savedImages.map((image) => (
|
||||
{allSavedImages && allSavedImages.length > 0 ? (
|
||||
allSavedImages.map((image) => (
|
||||
<li
|
||||
key={image.id}
|
||||
className={`relative flex items-center justify-between border p-2 ${
|
||||
@ -34,16 +40,14 @@ export default function ImageSelection({ projectId, selectedImages, setSelectedI
|
||||
>
|
||||
<span className="truncate">{image.imageTitle}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
{selectedImages.includes(image.id) && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
onClick={() => handleImageSelect(image.id)}
|
||||
className="p-0"
|
||||
>
|
||||
X
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={selectedImages.includes(image.id) ? 'destructive' : 'outline'}
|
||||
size="xs"
|
||||
onClick={() => handleImageSelect(image.id)}
|
||||
className="p-0"
|
||||
>
|
||||
{selectedImages.includes(image.id) ? '선택 해제' : '선택'}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
|
90
frontend/src/components/ReviewForm/index.tsx
Normal file
90
frontend/src/components/ReviewForm/index.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
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 ImageSelection from '@/components/ImageSelection';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
interface ReviewFormProps {
|
||||
projects: { id: string; title: string }[];
|
||||
selectedProjectId: string | null;
|
||||
setSelectedProjectId: (id: string) => void;
|
||||
selectedImages: number[];
|
||||
setSelectedImages: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
onSubmit: (data: { title: string; content: string }) => void;
|
||||
}
|
||||
|
||||
export default function ReviewForm({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
setSelectedProjectId,
|
||||
selectedImages,
|
||||
setSelectedImages,
|
||||
onSubmit,
|
||||
}: ReviewFormProps): JSX.Element {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<{ title: string; content: string }>();
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="project">프로젝트 선택</Label>
|
||||
<Select onValueChange={(value) => setSelectedProjectId(value)}>
|
||||
<SelectTrigger id="project">
|
||||
<SelectValue placeholder="프로젝트를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.length ? (
|
||||
projects.map((project) => (
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
value={project.id}
|
||||
>
|
||||
{project.title}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem
|
||||
disabled
|
||||
value={'true'}
|
||||
>
|
||||
프로젝트가 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="title">제목</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="리뷰 제목을 입력하세요"
|
||||
{...register('title', { required: '제목을 입력해주세요.' })}
|
||||
/>
|
||||
{errors.title && <p className="text-red-500">{errors.title.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="content">내용</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder="리뷰 내용을 입력하세요"
|
||||
{...register('content', { required: '내용을 입력해주세요.' })}
|
||||
/>
|
||||
{errors.content && <p className="text-red-500">{errors.content.message}</p>}
|
||||
</div>
|
||||
|
||||
{selectedProjectId && (
|
||||
<ImageSelection
|
||||
projectId={selectedProjectId}
|
||||
selectedImages={selectedImages}
|
||||
setSelectedImages={setSelectedImages}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
39
frontend/src/hooks/useRecursiveSavedImages.ts
Normal file
39
frontend/src/hooks/useRecursiveSavedImages.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import useFolderQuery from '@/queries/folders/useFolderQuery';
|
||||
import { ImageResponse, ChildFolder } from '@/types';
|
||||
import { getFolder } from '@/api/folderApi';
|
||||
|
||||
export default function useRecursiveSavedImages(projectId: string, folderId: number) {
|
||||
const [allSavedImages, setAllSavedImages] = useState<ImageResponse[]>([]);
|
||||
const { data: folderData, isLoading, error } = useFolderQuery(projectId, folderId);
|
||||
|
||||
const fetchAllSavedImages = useCallback(
|
||||
async (folderId: number): Promise<ImageResponse[]> => {
|
||||
const folder = await getFolder(projectId, folderId);
|
||||
|
||||
const childFolderImagesPromises = folder.children.map(async (childFolder: ChildFolder) => {
|
||||
return fetchAllSavedImages(childFolder.id);
|
||||
});
|
||||
|
||||
const childFolderImages = await Promise.all(childFolderImagesPromises);
|
||||
|
||||
const savedImages = folder.images.filter((image) => image.status === 'SAVE');
|
||||
console.log(savedImages);
|
||||
return [...savedImages, ...childFolderImages.flat()];
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const getAllSavedImages = async () => {
|
||||
if (folderData) {
|
||||
const images = await fetchAllSavedImages(folderId);
|
||||
setAllSavedImages(images);
|
||||
}
|
||||
};
|
||||
|
||||
getAllSavedImages();
|
||||
}, [folderData, fetchAllSavedImages, folderId]);
|
||||
|
||||
return { allSavedImages, isLoading, error };
|
||||
}
|
@ -1,104 +1,81 @@
|
||||
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 ImageSelection from '@/components/ImageSelection';
|
||||
import useReviewRequest from '@/hooks/useReviewRequest';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import useCreateReviewQuery from '@/queries/reviews/useCreateReviewQuery';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ReviewForm from '@/components/ReviewForm';
|
||||
import useReviewRequest from '@/hooks/useReviewRequest';
|
||||
import { useState } from 'react';
|
||||
export default function ReviewRequest(): JSX.Element {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
errors,
|
||||
projects,
|
||||
onSubmit,
|
||||
selectedProjectId,
|
||||
setSelectedProjectId,
|
||||
selectedImages,
|
||||
setSelectedImages,
|
||||
} = useReviewRequest();
|
||||
const { profile } = useAuthStore((state) => state);
|
||||
const memberId = profile?.id ?? 0;
|
||||
const navigate = useNavigate();
|
||||
const { workspaceId } = useParams<{ workspaceId?: string }>();
|
||||
|
||||
const { projects, selectedProjectId, setSelectedProjectId, selectedImages, setSelectedImages } = useReviewRequest();
|
||||
|
||||
const handleSuccess = () => {
|
||||
navigate(`/admin/${workspaceId}/reviews`);
|
||||
};
|
||||
|
||||
const createReview = useCreateReviewQuery();
|
||||
|
||||
const [formData, setFormData] = useState<{ title: string; content: string } | null>(null);
|
||||
|
||||
const handleReviewSubmit = (data: { title: string; content: string }) => {
|
||||
setFormData(data);
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
if (!formData) return;
|
||||
|
||||
const reviewData = {
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
imageIds: selectedImages,
|
||||
};
|
||||
|
||||
createReview.mutate(
|
||||
{
|
||||
projectId: Number(selectedProjectId),
|
||||
memberId,
|
||||
reviewData,
|
||||
},
|
||||
{
|
||||
onSuccess: handleSuccess,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="review-request-container p-4">
|
||||
<h1 className="mb-4 text-2xl font-bold">리뷰 요청</h1>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="project">프로젝트 선택</Label>
|
||||
<Select onValueChange={(value) => setSelectedProjectId(value)}>
|
||||
<SelectTrigger id="project">
|
||||
<SelectValue placeholder="프로젝트를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.length ? (
|
||||
projects.map((project) => (
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
value={project.id.toString()}
|
||||
>
|
||||
{project.title}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem
|
||||
disabled
|
||||
value={'true'}
|
||||
>
|
||||
프로젝트가 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ReviewForm
|
||||
projects={projects.map((project) => ({ id: project.id.toString(), title: project.title }))}
|
||||
selectedProjectId={selectedProjectId}
|
||||
setSelectedProjectId={setSelectedProjectId}
|
||||
selectedImages={selectedImages}
|
||||
setSelectedImages={setSelectedImages}
|
||||
onSubmit={handleReviewSubmit}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="title">제목</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="리뷰 제목을 입력하세요"
|
||||
{...register('title', { required: '제목을 입력해주세요.' })}
|
||||
/>
|
||||
{errors.title && <p className="text-red-500">{errors.title.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="content">내용</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder="리뷰 내용을 입력하세요"
|
||||
{...register('content', { required: '내용을 입력해주세요.' })}
|
||||
/>
|
||||
{errors.content && <p className="text-red-500">{errors.content.message}</p>}
|
||||
</div>
|
||||
|
||||
{selectedProjectId && (
|
||||
<ImageSelection
|
||||
projectId={selectedProjectId}
|
||||
selectedImages={selectedImages}
|
||||
setSelectedImages={setSelectedImages}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="actions mt-6 flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
type="submit"
|
||||
disabled={!selectedProjectId}
|
||||
>
|
||||
리뷰 요청
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="actions mt-6 flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleButtonClick} // 버튼 클릭 시에만 mutate 실행
|
||||
disabled={!selectedProjectId || !formData}
|
||||
>
|
||||
리뷰 요청
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
18
frontend/src/queries/images/useChangeImageStatusQuery.ts
Normal file
18
frontend/src/queries/images/useChangeImageStatusQuery.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { changeImageStatus } from '@/api/imageApi';
|
||||
import { ImageStatusChangeRequest } from '@/types';
|
||||
|
||||
export default function useChangeImageStatusQuery(onSuccess?: () => void) {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
imageId,
|
||||
memberId,
|
||||
statusChangeRequest,
|
||||
}: {
|
||||
imageId: number;
|
||||
memberId: number;
|
||||
statusChangeRequest: ImageStatusChangeRequest;
|
||||
}) => changeImageStatus(imageId, memberId, statusChangeRequest),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
@ -15,8 +15,9 @@ export default function useCreateReviewQuery() {
|
||||
memberId: number;
|
||||
reviewData: ReviewRequest;
|
||||
}) => createReview(projectId, memberId, reviewData),
|
||||
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['reviewList', variables.projectId, variables.memberId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['reviewDetail', variables.projectId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user