Skip to content

Commit

Permalink
Merge pull request #62 from Invincible-Backend-Study/fe/dev
Browse files Browse the repository at this point in the history
feat: 관리자 질문 등록 기능 구현
  • Loading branch information
JaeHongDev authored Jul 8, 2024
2 parents a483afb + a1320d6 commit 70ef9bb
Show file tree
Hide file tree
Showing 40 changed files with 1,012 additions and 5 deletions.
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
"@nextui-org/react": "^2.4.1",
"@tanstack/react-query": "^5.40.1",
"axios": "^1.7.2",
"browser-image-compression": "^2.0.2",
"framer-motion": "^11.2.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-drag-drop-files": "^2.3.10",
"react-error-boundary": "^4.0.13",
"react-icons": "^5.2.1",
"react-router-dom": "^6.23.1",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/Interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const handleTokenError = async(error: AxiosError<ErrorResponseData>) => {
}

if (
status === 400 && (data.code >= 10000 || data.code <= 10006)
status === 400 && (data.code >= 10000 && data.code <= 10006)
) {
localStorage.removeItem(TOKEN.ACCESS);
window.location.href = PATH.AUTH;
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/api/image/PostImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {axiosInstance} from "@/api/AxiosInstance";
import {END_POINT} from "@/constants/api";

interface PostImageBody {
thumbnail: FormData;
}

interface ImageUrl {
imageUrl: string;
}

export const postImage = async (body: PostImageBody) => {
const { data } = await axiosInstance.post<ImageUrl>(END_POINT.admin.IMAGE, body.thumbnail);
return data.imageUrl;
}
11 changes: 11 additions & 0 deletions frontend/src/api/question/GetQuestionSetByAdmin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {axiosInstance} from "@/api/AxiosInstance";
import {END_POINT} from "@/constants/api";
import {Pageable} from "@/types/api";
import {QuestionSetResponse} from "@/types/admin/questionSet";


export const getQuestionSetByAdmin = async (pageable: Pageable) => {
const {data} = await axiosInstance.get<QuestionSetResponse>(END_POINT.admin.QUESTION_SETS(pageable))
return data;
}

13 changes: 13 additions & 0 deletions frontend/src/api/question/GetQuestionsByAdmin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {axiosInstance} from "@/api/AxiosInstance";
import {END_POINT} from "@/constants/api";
import {QuestionSearchResponse} from "@/types/admin/question";


export const getQuestionsByAdmin = async (questionSetId: number | undefined) => {
if(questionSetId === undefined){
return []
}
const {data} = await axiosInstance.get<QuestionSearchResponse>(END_POINT.admin.QUESTIONS({questionSetId}))

return data.questions;
}
8 changes: 8 additions & 0 deletions frontend/src/api/question/PostAdminQuestion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {Question} from "@/types/admin/question";
import {axiosInstance} from "@/api/AxiosInstance";
import {END_POINT} from "@/constants/api";


export const postAdminQuestion = async (question: Question) => {
await axiosInstance.post(END_POINT.admin.QUESTION, {...question, action: "CREATE"}) ;
}
13 changes: 13 additions & 0 deletions frontend/src/api/question/PostAdminQuestionSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {axiosInstance} from "@/api/AxiosInstance";
import {END_POINT} from "@/constants/api";
import {QuestionSetSaveBody} from "@/types/admin/questionSet";


interface QuestionSaveResponse{
questionSetId: number;
}

export const postAdminQuestionSet = async (body: QuestionSetSaveBody) => {
const { data } = await axiosInstance.post<QuestionSaveResponse>(END_POINT.admin.QUESTION_SET, body);
return data;
}
18 changes: 18 additions & 0 deletions frontend/src/components/AdminEditorButton/AdminEditorButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {Button, ButtonGroup} from "@nextui-org/react";
import {CiFloppyDisk, CiFolderOn, CiSquarePlus} from "react-icons/ci";


interface AdminEditorButtonProps {
add?: () => void;
file?:() => void;
save?: () => void;
}

const AdminEditorButton = ({add, file, save}: AdminEditorButtonProps) => {
return <ButtonGroup className="pt-3 pb-3" variant="ghost" radius="none">
{add && <Button isIconOnly onClick={add}><CiSquarePlus size={26}/></Button>}
{file && <Button isIconOnly onClick={file}><CiFolderOn size={26}/></Button>}
{save && <Button isIconOnly onClick={save}><CiFloppyDisk size={26}/></Button> }
</ButtonGroup>
}
export default AdminEditorButton;
37 changes: 37 additions & 0 deletions frontend/src/components/QuestionSetPreview/QuestionSetPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import QuestionSetItem from "@/components/QuestionSetItem/QuestionSetItem";
import {QuestionSetRow} from "@/types/admin/questionSet";

interface QuestionSetPreviewProps {
questionSet?: QuestionSetRow
}

const QuestionSetPreview = ({questionSet}: QuestionSetPreviewProps) => {
return (
<div className='w-full h-[50%] p-3'>
<span>미리보기</span>
<div className="flex flex-row gap-3">
{questionSet && <QuestionSetItem
questionSet={{
questionSetId: 1,
count: 10,
title: questionSet.title,
tailQuestionDepth: 10,
description: questionSet.description,
thumbnailUrl: questionSet.thumbnailUrl
}}
openInterviewSetting={() => {}}/>}
<QuestionSetItem questionSet={{
questionSetId: 1,
count: 10,
title: "여기서 편집해세요",
tailQuestionDepth: 10,
description: "여기서 자유롭게 편집하시죠",
thumbnailUrl: "https://storage.googleapis.com/mubaegseu/jpa.png"
}} openInterviewSetting={() => {}}/>
</div>
</div>
)
}


export default QuestionSetPreview;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {Button} from "@nextui-org/react";
import {CiPen, CiTrash} from "react-icons/ci";

const QuestionSetActionColumn = () => {
return <>
<Button isIconOnly variant="light"><CiPen size={20}/></Button>
<Button isIconOnly variant="light"><CiTrash size={20}/></Button>
</>
}

export default QuestionSetActionColumn;
12 changes: 12 additions & 0 deletions frontend/src/components/QuestionSetTable/QuestionSetDateColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {dateToString} from "@/utils/Date";


interface QuestionSetDateColumnProps {
date?: string | Date
}

const QuestionSetDateColumn = ({date} : QuestionSetDateColumnProps) => {
return <div>{date === undefined ? "" : dateToString(new Date(date))}</div>
}

export default QuestionSetDateColumn;
93 changes: 93 additions & 0 deletions frontend/src/components/QuestionSetTable/QuestionSetEditForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {Button, Image, Input, ModalBody, ModalContent, ModalHeader, Slider} from "@nextui-org/react";
import {QuestionSetRow} from "@/types/admin/questionSet";
import {ChangeQuestionForm} from "@/components/QuestionSetTable/useQuestionSetEditForm";
import {FileUploader} from "react-drag-drop-files";
import {useCallback} from "react";
import {useImageUploadMutation} from "@/hooks/api/image/useImageUploadMutation";
import {toast} from "sonner";
import imageCompression from "browser-image-compression";


interface QuestionSetEditFormProps {
form: QuestionSetRow;
change: ChangeQuestionForm;
confirm:() => void;
}

const QuestionSetEditForm = ({confirm, change, form: {description, title, thumbnailUrl, defaultTailQuestionDepth}}: QuestionSetEditFormProps) => {
const imageUploadMutation = useImageUploadMutation();


const compressImage = useCallback(async (originalImageFile: File) => {
let imageFile: File;
try {
const compressedImageFile = await imageCompression(
originalImageFile,
{
maxSizeMB: 1.5
}
);

const fileName = originalImageFile.name;

const fileType = compressedImageFile.type;

imageFile = new File([compressedImageFile], fileName, { type: fileType });
} catch (e) {
imageFile = originalImageFile;
}

return imageFile;
}, []);


const handleChange = useCallback(async(file: File) => {

const compressedFile = await compressImage(file);
const formData = new FormData();
formData.append("thumbnail", compressedFile);

imageUploadMutation.mutate({thumbnail: formData}, {
onSuccess: (thumbnailUrl) => {
change("thumbnailUrl", thumbnailUrl)
},
onError: () => {
toast.error("이미지 업로드에 실패했습니다.")
}
});
}, []);





return (
<ModalContent>
{onClose => (
<>
<ModalHeader>질문 집합 등록/편집 폼</ModalHeader>
<ModalBody>
<span className="text-sm">바디용</span>

<Input placeholder={"제목"} value={title} onValueChange={(t) => change("title", t)}/>

<Input placeholder={"설명"} value={description} onValueChange={t => change("description",t)}/>

<Slider label="기본 꼬리질문 개수" minValue={0} maxValue={20} defaultValue={defaultTailQuestionDepth} onChange={(n) => typeof n === 'number' && change("defaultTailQuestionDepth", n)}/>

<FileUploader label="이미지를 올려주세요" handleChange={handleChange} name="file" types={["PNG", "JPEG", "WEBP"]}/>

{thumbnailUrl && <Image src={thumbnailUrl}/>}

<Button onClick={() => {
confirm();
onClose();
}}>등록</Button>

</ModalBody>
</>
)}
</ModalContent>
)
}
export default QuestionSetEditForm;
10 changes: 10 additions & 0 deletions frontend/src/components/QuestionSetTable/QuestionSetIdColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@


interface QuestionSetIdColumnProps {
id: number;
}

const QuestionSetIdColumn = ({id}: QuestionSetIdColumnProps) => {
return <div>{id >= 0 ? id : "NEW"}</div>
}
export default QuestionSetIdColumn;
100 changes: 100 additions & 0 deletions frontend/src/components/QuestionSetTable/QuestionSetTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
ScrollShadow,
Spinner,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow
} from "@nextui-org/react";
import {columns} from "@/components/QuestionSetTable/QuestionSetTableConstant";
import {QuestionSetRow} from "@/types/admin/questionSet";
import useQuestionSetTable from "@/components/QuestionSetTable/useQuestionSetTable";
import React, {useCallback, useMemo} from "react";
import QuestionSetIdColumn from "@/components/QuestionSetTable/QuestionSetIdColumn";
import QuestionSetActionColumn from "@/components/QuestionSetTable/QuestionSetActionColumn";
import QuestionSetTextColumn from "@/components/QuestionSetTable/QuestionSetTextColumn";
import InterviewHistoryPagination from "@/components/InterviewHistoryTable/InterviewHistoryPagination";


interface QuestionSetTableProps {
rows: QuestionSetRow[],
page: number;
totalPages?: number;
changePage?: (e: number) => void;
isLoading: boolean,
selectionChange: (e: number) => void;
}


const QuestionSetTable = ({rows, page, totalPages, isLoading, changePage, selectionChange}: QuestionSetTableProps) => {
const {tableClassNames} = useQuestionSetTable();

const bottomContent = useMemo(() => {
return <InterviewHistoryPagination page={page} totalPages={totalPages} changePage={changePage}/>
},[page, totalPages])

const renderCell = useCallback((item: QuestionSetRow, cellType: React.Key) => {
const cellValue = item[cellType as keyof QuestionSetRow];

if(cellType === "questionSetId") {
return <QuestionSetIdColumn id={item.questionSetId}/>
}

if(cellType === "action") {
return <QuestionSetActionColumn/>
}

return <QuestionSetTextColumn text={cellValue}/>
}, [])

return(
<ScrollShadow className="max-h-full">
<Table
aria-label="simple table"
isCompact
removeWrapper
onSelectionChange={(selection) => {
if([...selection].length === 0) {
selectionChange(-1);
}
for(const k of selection) {
selectionChange(Number(k));
}
}}
color="success"
selectionMode="single"
classNames={tableClassNames}

bottomContent={bottomContent}
bottomContentPlacement="outside"
>
<TableHeader columns={columns}>
{(column) => (
<TableColumn
key={column.uid}
align={column.uid === "actions" ? "center" : "start"}
width={"10%"}
allowsSorting={column.sortable}
>
{column.name}
</TableColumn>
)}
</TableHeader>
<TableBody
loadingContent={<Spinner label="Loading..." />}
isLoading={isLoading}
items={rows} emptyContent={"질문 이력이 없습니다."}>
{(row) => (
<TableRow key={row.questionSetId}>
{(columnKey) => <TableCell>{renderCell(row, columnKey)}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
</ScrollShadow>
)
}

export default QuestionSetTable;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@



export const columns = [
{name: "질문 집합 ID", uid: "questionSetId", sortable: true},
{name: "질문 집합 제목", uid: "title"},
{name: "질문 집합 설명", uid: "description"},
{name: "이미지 주소", uid: "thumbnailUrl"},
{name: "액션", uid:"action" }
];

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@


const QuestionSetTablePagination = () => {

}
Loading

0 comments on commit 70ef9bb

Please sign in to comment.