diff --git a/frontend/package.json b/frontend/package.json index eaa1f41..0cb9632 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/Interceptors.ts b/frontend/src/api/Interceptors.ts index dd4abf0..308535d 100644 --- a/frontend/src/api/Interceptors.ts +++ b/frontend/src/api/Interceptors.ts @@ -46,7 +46,7 @@ export const handleTokenError = async(error: AxiosError) => { } 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; diff --git a/frontend/src/api/image/PostImage.ts b/frontend/src/api/image/PostImage.ts new file mode 100644 index 0000000..9db1f8f --- /dev/null +++ b/frontend/src/api/image/PostImage.ts @@ -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(END_POINT.admin.IMAGE, body.thumbnail); + return data.imageUrl; +} diff --git a/frontend/src/api/question/GetQuestionSetByAdmin.ts b/frontend/src/api/question/GetQuestionSetByAdmin.ts new file mode 100644 index 0000000..140d138 --- /dev/null +++ b/frontend/src/api/question/GetQuestionSetByAdmin.ts @@ -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(END_POINT.admin.QUESTION_SETS(pageable)) + return data; +} + diff --git a/frontend/src/api/question/GetQuestionsByAdmin.ts b/frontend/src/api/question/GetQuestionsByAdmin.ts new file mode 100644 index 0000000..028c613 --- /dev/null +++ b/frontend/src/api/question/GetQuestionsByAdmin.ts @@ -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(END_POINT.admin.QUESTIONS({questionSetId})) + + return data.questions; +} diff --git a/frontend/src/api/question/PostAdminQuestion.ts b/frontend/src/api/question/PostAdminQuestion.ts new file mode 100644 index 0000000..7835bc5 --- /dev/null +++ b/frontend/src/api/question/PostAdminQuestion.ts @@ -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"}) ; +} diff --git a/frontend/src/api/question/PostAdminQuestionSet.ts b/frontend/src/api/question/PostAdminQuestionSet.ts new file mode 100644 index 0000000..364d6db --- /dev/null +++ b/frontend/src/api/question/PostAdminQuestionSet.ts @@ -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(END_POINT.admin.QUESTION_SET, body); + return data; +} diff --git a/frontend/src/components/AdminEditorButton/AdminEditorButton.tsx b/frontend/src/components/AdminEditorButton/AdminEditorButton.tsx new file mode 100644 index 0000000..77a9313 --- /dev/null +++ b/frontend/src/components/AdminEditorButton/AdminEditorButton.tsx @@ -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 + {add && } + {file && } + {save && } + +} +export default AdminEditorButton; diff --git a/frontend/src/components/QuestionSetPreview/QuestionSetPreview.tsx b/frontend/src/components/QuestionSetPreview/QuestionSetPreview.tsx new file mode 100644 index 0000000..d41683a --- /dev/null +++ b/frontend/src/components/QuestionSetPreview/QuestionSetPreview.tsx @@ -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 ( +
+ 미리보기 +
+ {questionSet && {}}/>} + {}}/> +
+
+ ) +} + + +export default QuestionSetPreview; diff --git a/frontend/src/components/QuestionSetTable/QuestionSetActionColumn.tsx b/frontend/src/components/QuestionSetTable/QuestionSetActionColumn.tsx new file mode 100644 index 0000000..6f480f0 --- /dev/null +++ b/frontend/src/components/QuestionSetTable/QuestionSetActionColumn.tsx @@ -0,0 +1,11 @@ +import {Button} from "@nextui-org/react"; +import {CiPen, CiTrash} from "react-icons/ci"; + +const QuestionSetActionColumn = () => { + return <> + + + +} + +export default QuestionSetActionColumn; diff --git a/frontend/src/components/QuestionSetTable/QuestionSetDateColumn.tsx b/frontend/src/components/QuestionSetTable/QuestionSetDateColumn.tsx new file mode 100644 index 0000000..dadf7eb --- /dev/null +++ b/frontend/src/components/QuestionSetTable/QuestionSetDateColumn.tsx @@ -0,0 +1,12 @@ +import {dateToString} from "@/utils/Date"; + + +interface QuestionSetDateColumnProps { + date?: string | Date +} + +const QuestionSetDateColumn = ({date} : QuestionSetDateColumnProps) => { + return
{date === undefined ? "" : dateToString(new Date(date))}
+} + +export default QuestionSetDateColumn; diff --git a/frontend/src/components/QuestionSetTable/QuestionSetEditForm.tsx b/frontend/src/components/QuestionSetTable/QuestionSetEditForm.tsx new file mode 100644 index 0000000..b4eabb3 --- /dev/null +++ b/frontend/src/components/QuestionSetTable/QuestionSetEditForm.tsx @@ -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 ( + + {onClose => ( + <> + 질문 집합 등록/편집 폼 + + 바디용 + + change("title", t)}/> + + change("description",t)}/> + + typeof n === 'number' && change("defaultTailQuestionDepth", n)}/> + + + + {thumbnailUrl && } + + + + + + )} + + ) +} +export default QuestionSetEditForm; diff --git a/frontend/src/components/QuestionSetTable/QuestionSetIdColumn.tsx b/frontend/src/components/QuestionSetTable/QuestionSetIdColumn.tsx new file mode 100644 index 0000000..c24601c --- /dev/null +++ b/frontend/src/components/QuestionSetTable/QuestionSetIdColumn.tsx @@ -0,0 +1,10 @@ + + +interface QuestionSetIdColumnProps { + id: number; +} + +const QuestionSetIdColumn = ({id}: QuestionSetIdColumnProps) => { + return
{id >= 0 ? id : "NEW"}
+} +export default QuestionSetIdColumn; diff --git a/frontend/src/components/QuestionSetTable/QuestionSetTable.tsx b/frontend/src/components/QuestionSetTable/QuestionSetTable.tsx new file mode 100644 index 0000000..0add552 --- /dev/null +++ b/frontend/src/components/QuestionSetTable/QuestionSetTable.tsx @@ -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 + },[page, totalPages]) + + const renderCell = useCallback((item: QuestionSetRow, cellType: React.Key) => { + const cellValue = item[cellType as keyof QuestionSetRow]; + + if(cellType === "questionSetId") { + return + } + + if(cellType === "action") { + return + } + + return + }, []) + + return( + + { + if([...selection].length === 0) { + selectionChange(-1); + } + for(const k of selection) { + selectionChange(Number(k)); + } + }} + color="success" + selectionMode="single" + classNames={tableClassNames} + + bottomContent={bottomContent} + bottomContentPlacement="outside" + > + + {(column) => ( + + {column.name} + + )} + + } + isLoading={isLoading} + items={rows} emptyContent={"질문 이력이 없습니다."}> + {(row) => ( + + {(columnKey) => {renderCell(row, columnKey)}} + + )} + +
+
+ ) +} + +export default QuestionSetTable; diff --git a/frontend/src/components/QuestionSetTable/QuestionSetTableConstant.ts b/frontend/src/components/QuestionSetTable/QuestionSetTableConstant.ts new file mode 100644 index 0000000..94548c0 --- /dev/null +++ b/frontend/src/components/QuestionSetTable/QuestionSetTableConstant.ts @@ -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" } +]; + diff --git a/frontend/src/components/QuestionSetTable/QuestionSetTablePagination.tsx b/frontend/src/components/QuestionSetTable/QuestionSetTablePagination.tsx new file mode 100644 index 0000000..158f803 --- /dev/null +++ b/frontend/src/components/QuestionSetTable/QuestionSetTablePagination.tsx @@ -0,0 +1,5 @@ + + +const QuestionSetTablePagination = () => { + +} diff --git a/frontend/src/components/QuestionSetTable/QuestionSetTextColumn.tsx b/frontend/src/components/QuestionSetTable/QuestionSetTextColumn.tsx new file mode 100644 index 0000000..8abc810 --- /dev/null +++ b/frontend/src/components/QuestionSetTable/QuestionSetTextColumn.tsx @@ -0,0 +1,10 @@ + +interface QuestionSetTextColumnProps { + text?: string | number; +} + +const QuestionSetTextColumn = ({text}: QuestionSetTextColumnProps) => { + return
{text}
+} + +export default QuestionSetTextColumn; diff --git a/frontend/src/components/QuestionSetTable/useQuestionSetEditForm.ts b/frontend/src/components/QuestionSetTable/useQuestionSetEditForm.ts new file mode 100644 index 0000000..9cb809f --- /dev/null +++ b/frontend/src/components/QuestionSetTable/useQuestionSetEditForm.ts @@ -0,0 +1,36 @@ +import {useCallback, useMemo, useState} from "react"; +import {QuestionSetRow} from "@/types/admin/questionSet"; + + +const INITIAL_STATE = { + title: "", + description: "", + thumbnailUrl: "", + questionSetId: -1, + defaultTailQuestionDepth: 0, +} + +export type ChangeQuestionForm = (key: K, value: QuestionSetRow[K]) => void; + +const useQuestionSetEditForm = () => { + const [form, setForm] = useState(INITIAL_STATE); + + const updateInputValue = useCallback( (key: K, value: QuestionSetRow[K]) => { + setForm((prev) => ({ + ...prev, + [key]: value + })) + }, [form]); + const memoizedValues = useMemo(() => ({ + form, + updateInputValue + }), [form, updateInputValue]); + + + return { + form: memoizedValues.form, updateInputValue:memoizedValues.updateInputValue + } + +} + +export default useQuestionSetEditForm; diff --git a/frontend/src/components/QuestionSetTable/useQuestionSetTable.tsx b/frontend/src/components/QuestionSetTable/useQuestionSetTable.tsx new file mode 100644 index 0000000..2dc3dbd --- /dev/null +++ b/frontend/src/components/QuestionSetTable/useQuestionSetTable.tsx @@ -0,0 +1,27 @@ +import {useMemo} from "react"; + +const useQuestionSetTable = () => { + const tableClassNames = useMemo( + () => ({ + th: ["bg-transparent", "text-default-500", "border-b", "border-divider"], + td: [ + // changing the rows border radius + // first + "group-data-[first=true]:first:before:rounded-none", + "group-data-[first=true]:last:before:rounded-none", + // middle + "group-data-[middle=true]:before:rounded-none", + // last + "group-data-[last=true]:first:before:rounded-none", + "group-data-[last=true]:last:before:rounded-none", + ], + }), + [], + ); + + return { + tableClassNames + } +} + +export default useQuestionSetTable; diff --git a/frontend/src/components/QuestionsTable/QuestionsTable.tsx b/frontend/src/components/QuestionsTable/QuestionsTable.tsx new file mode 100644 index 0000000..8e643c3 --- /dev/null +++ b/frontend/src/components/QuestionsTable/QuestionsTable.tsx @@ -0,0 +1,81 @@ +import { + ScrollShadow, + Spinner, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow +} from "@nextui-org/react"; +import {QuestionRow} from "@/types/admin/question"; +import useQuestionSetTable from "@/components/QuestionSetTable/useQuestionSetTable"; +import React, {useCallback} from "react"; +import QuestionSetIdColumn from "@/components/QuestionSetTable/QuestionSetIdColumn"; +import QuestionSetActionColumn from "@/components/QuestionSetTable/QuestionSetActionColumn"; +import QuestionSetTextColumn from "@/components/QuestionSetTable/QuestionSetTextColumn"; +import {columns} from "@/components/QuestionsTable/QuestionsTableConstant"; + + +interface QuestionTableProps { + rows: QuestionRow[], + isLoading: boolean +} + +const QuestionsTable = ({rows, isLoading}: QuestionTableProps) => { + const {tableClassNames} = useQuestionSetTable(); + + const renderCell = useCallback((item: QuestionRow, cellType: React.Key) => { + const cellValue = item[cellType as keyof QuestionRow]; + + if(cellType === "questionSetId") { + return + } + + if(cellType === "action") { + return + } + + return + }, []) + + return ( + + console.log(key)} + color="success" + selectionMode="single" + classNames={tableClassNames} + > + + {(column) => ( + + {column.name} + + )} + + } + isLoading={isLoading} + items={rows} emptyContent={"질문이 없습니다."}> + {(row) => ( + + {(columnKey) => {renderCell(row, columnKey)}} + + )} + +
+
+ ) + +} + +export default QuestionsTable; diff --git a/frontend/src/components/QuestionsTable/QuestionsTableConstant.ts b/frontend/src/components/QuestionsTable/QuestionsTableConstant.ts new file mode 100644 index 0000000..e069538 --- /dev/null +++ b/frontend/src/components/QuestionsTable/QuestionsTableConstant.ts @@ -0,0 +1,9 @@ + +export const columns = [ + {name: "질문 ID", uid: "questionId", sortable: true}, + {name: "질문 순서", uid: "sequence"}, + {name: "질문 내용", uid: "question"}, +]; + + + diff --git a/frontend/src/components/TablePagination/TablePagination.tsx b/frontend/src/components/TablePagination/TablePagination.tsx new file mode 100644 index 0000000..d5692c3 --- /dev/null +++ b/frontend/src/components/TablePagination/TablePagination.tsx @@ -0,0 +1,30 @@ +import {Pagination} from "@nextui-org/react"; + + +interface TablePaginationProps{ + totalPages?: number; + page: number; + changePage?: (page: number) => void; +} + +const TablePagination = ({totalPages, page, changePage}: TablePaginationProps) => { + + return ( +
+ +
+ ); +} + +export default TablePagination; diff --git a/frontend/src/components/TextPreview/TextPreview.tsx b/frontend/src/components/TextPreview/TextPreview.tsx new file mode 100644 index 0000000..dfc3fd6 --- /dev/null +++ b/frontend/src/components/TextPreview/TextPreview.tsx @@ -0,0 +1,40 @@ +import {Button, ModalBody, ModalContent, ModalHeader, ScrollShadow} from "@nextui-org/react"; +import {FileUploader} from "react-drag-drop-files"; +import {useFileReader} from "@/hooks/useFileReader"; + + +interface TextPreviewProps { + saveAction: (textLines: string[]) => void; + close: () => void; +} + + +const TextPreview = ({saveAction, close} : TextPreviewProps) => { + const {lines, handleChange} = useFileReader(); + const handleClick = () => { + + saveAction(lines); + close(); + } + + return ( + + {_ => ( + <> + 파일 읽기 + + + +
    + {lines.map((t, key) =>
  • {t}
  • )} +
+
+ +
+ + )} +
+ ) +} + +export default TextPreview; diff --git a/frontend/src/constants/api.ts b/frontend/src/constants/api.ts index c7e5c29..1c66edd 100644 --- a/frontend/src/constants/api.ts +++ b/frontend/src/constants/api.ts @@ -12,6 +12,11 @@ const wrap = (url: string) => { } export const END_POINT = { + + /****************************************************************** + * member + * ****************************************************************** + */ ME: '/members/me', TAIL_QUESTION_SUBMIT: "/tail-questions/submit", @@ -29,7 +34,16 @@ export const END_POINT = { LOGOUT: "/auth/logout", TOKEN_REISSUE: "/auth/token/reissue", - QUESTION_SETS: wrap( "/question-set") + QUESTION_SETS: wrap( "/question-set"), + + + admin: { + IMAGE: "/admin/images", + QUESTION_SET: "/admin/question-set", + QUESTION_SETS: wrap("/admin/question-set"), + QUESTION: "/admin/questions", + QUESTIONS: wrap( "/admin/questions") + } } as const; diff --git a/frontend/src/hooks/api/image/useImageUploadMutation.ts b/frontend/src/hooks/api/image/useImageUploadMutation.ts new file mode 100644 index 0000000..b3bdf09 --- /dev/null +++ b/frontend/src/hooks/api/image/useImageUploadMutation.ts @@ -0,0 +1,9 @@ +import {useMutation} from "@tanstack/react-query"; +import {postImage} from "@/api/image/PostImage"; + +export const useImageUploadMutation = () => { + return useMutation({ + mutationKey: ['imageUpload'], + mutationFn: postImage + }) +} diff --git a/frontend/src/hooks/api/question/useAdminQuestionSetQuery.ts b/frontend/src/hooks/api/question/useAdminQuestionSetQuery.ts new file mode 100644 index 0000000..9f9ba28 --- /dev/null +++ b/frontend/src/hooks/api/question/useAdminQuestionSetQuery.ts @@ -0,0 +1,22 @@ +import {keepPreviousData, useQuery} from "@tanstack/react-query"; +import {getQuestionSetByAdmin} from "@/api/question/GetQuestionSetByAdmin"; + +const size = 10; + + +export const useAdminQuestionSetQuery = (page: number) => { + const {data, refetch, isLoading, isRefetching} = useQuery({ + queryKey: ['admin question set', page], + queryFn: () => getQuestionSetByAdmin({page: page - 1, size}), + placeholderData: keepPreviousData, + }); + + + + return { + totalPages: data?.totalPages, + data: data?.content, + refetch, + isLoading: isLoading || isRefetching + } +} diff --git a/frontend/src/hooks/api/question/useAdminQuestionsQuery.ts b/frontend/src/hooks/api/question/useAdminQuestionsQuery.ts new file mode 100644 index 0000000..9940457 --- /dev/null +++ b/frontend/src/hooks/api/question/useAdminQuestionsQuery.ts @@ -0,0 +1,23 @@ +import {keepPreviousData, useQuery} from "@tanstack/react-query"; +import {getQuestionsByAdmin} from "@/api/question/GetQuestionsByAdmin"; +import {useEffect} from "react"; + +export const useAdminQuestionsQuery = (questionSetId: number | undefined) => { + const {data: questions, refetch, isLoading, isRefetching} = useQuery({ + queryKey: ['admin questions', questionSetId], + queryFn: () => getQuestionsByAdmin(questionSetId), + placeholderData: keepPreviousData, + }); + + + useEffect(() => { + refetch() + }, [questionSetId]); + + + return { + questions: questions === undefined ? [] : questions, + refetch, + isLoading: isLoading || isRefetching + } +} diff --git a/frontend/src/hooks/api/question/useQuestionSaveMutation.ts b/frontend/src/hooks/api/question/useQuestionSaveMutation.ts new file mode 100644 index 0000000..7fa306d --- /dev/null +++ b/frontend/src/hooks/api/question/useQuestionSaveMutation.ts @@ -0,0 +1,9 @@ +import {useMutation} from "@tanstack/react-query"; +import {postAdminQuestion} from "@/api/question/PostAdminQuestion"; + +export const useQuestionSaveMutation = () => { + return useMutation({ + mutationKey: ['question save'], + mutationFn: postAdminQuestion + }) +} diff --git a/frontend/src/hooks/api/question/useQuestionSetMutation.ts b/frontend/src/hooks/api/question/useQuestionSetMutation.ts new file mode 100644 index 0000000..990fd85 --- /dev/null +++ b/frontend/src/hooks/api/question/useQuestionSetMutation.ts @@ -0,0 +1,13 @@ +import {useMutation} from "@tanstack/react-query"; +import {postAdminQuestionSet} from "@/api/question/PostAdminQuestionSet"; +import {toast} from "sonner"; + +export const useQuestionSetMutation = () => { + return useMutation({ + mutationKey: ['save question set'], + mutationFn: postAdminQuestionSet, + onError: error => { + toast.error(error.message); + } + }) +} diff --git a/frontend/src/hooks/useFileReader.ts b/frontend/src/hooks/useFileReader.ts new file mode 100644 index 0000000..7d50c2d --- /dev/null +++ b/frontend/src/hooks/useFileReader.ts @@ -0,0 +1,40 @@ +import {useEffect, useState} from "react"; + + +export const useFileReader = () => { + + const [file, setFile] = useState(); + const reader = new FileReader(); + const [lines, setLines]= useState([]); + + const handleChange = (file: File) => { + setFile(file); + }; + + useEffect(() => { + + + reader.onload = () => { + if(reader.result === null){ + setLines([]); + return ; + } + const text = reader.result.toString(); + + setLines(text.split("\n") + .map(s => s.trim()) + .filter(s => s.length !== 0)); + } + + if(file !== undefined){ + reader.readAsText(file); + } + + return () => { + reader.onload = null; + } + }, [file]) + + + return {lines, handleChange} +} diff --git a/frontend/src/pages/QuestionSetManage/useQuestionSetLoader.ts b/frontend/src/pages/QuestionSetManage/useQuestionSetLoader.ts new file mode 100644 index 0000000..0594a13 --- /dev/null +++ b/frontend/src/pages/QuestionSetManage/useQuestionSetLoader.ts @@ -0,0 +1,53 @@ +import {useCallback, useEffect, useState} from "react"; +import {useAdminQuestionSetQuery} from "@/hooks/api/question/useAdminQuestionSetQuery"; +import {QuestionSetRow} from "@/types/admin/questionSet"; + + +export const useQuestionSetLoader = () => { + const [selectedQuestionSetId, setSelectedQuestionSetId] = useState(); + const [selectedQuestionSet, setSelectedQuestionSet] = useState(); + const [page, setPage] = useState(0); + const {data: questionSetList, refetch, isLoading, totalPages} = useAdminQuestionSetQuery(page); + + useEffect(() => { + refetch() + }, [page]) + + const changePage = useCallback((n: number) => setPage(n) , [page]) + const changeSelectedQuestionSet = useCallback((n: number) => { + + // close + if(n <= 0){ + setSelectedQuestionSet(undefined); + setSelectedQuestionSetId(undefined); + return ; + } + + setSelectedQuestionSetId(n); + + if(questionSetList === undefined) { + return ; + } + const selectedIndex = questionSetList?.findIndex(questionSet=> questionSet.questionSetId === n); + if(selectedIndex === -1) { + return ; + } + setSelectedQuestionSet(questionSetList[selectedIndex]); + }, [selectedQuestionSetId]); + + return { + questionSetList, + pageInfo: { + page, + isLoading, + totalPages, + changePage, + }, + + selected: { + selectedQuestionSet, + selectedQuestionSetId, + changeSelectedQuestionSet + } + } +} diff --git a/frontend/src/pages/QuestionSetManage/useQuestionSetManage.ts b/frontend/src/pages/QuestionSetManage/useQuestionSetManage.ts new file mode 100644 index 0000000..3df632a --- /dev/null +++ b/frontend/src/pages/QuestionSetManage/useQuestionSetManage.ts @@ -0,0 +1,37 @@ +import {useCallback, useEffect, useState} from "react"; +import {QuestionSet, QuestionSetRow} from "@/types/admin/questionSet"; +import useQuestionSetEditForm from "@/components/QuestionSetTable/useQuestionSetEditForm"; +import {useQuestionSetMutation} from "@/hooks/api/question/useQuestionSetMutation"; + + +const useQuestionSetManage = (questions: QuestionSet[]) => { + // 임시 id + const {form,updateInputValue} = useQuestionSetEditForm(); + const questionSetSaveMutation = useQuestionSetMutation(); + const [newRows, setNewRows] = useState([]) + + + const handleRegisterNewQuestionSet = useCallback(() => { + questionSetSaveMutation.mutate({...form}, { + onSuccess: ({questionSetId}) => { + setNewRows(rows => [{...form, questionSetId}, ...rows]); + } + }) + }, [questionSetSaveMutation,form]) + + useEffect(() => { + setNewRows([]); + }, [questions]) + + + const rows: QuestionSetRow[] = [...newRows, ...questions]; + + return { + form, + updateInputValue, + handleRegisterNewQuestionSet, + rows, + } +} + +export default useQuestionSetManage; diff --git a/frontend/src/pages/QuestionSetManage/useQuestionsManage.ts b/frontend/src/pages/QuestionSetManage/useQuestionsManage.ts new file mode 100644 index 0000000..b386b1e --- /dev/null +++ b/frontend/src/pages/QuestionSetManage/useQuestionsManage.ts @@ -0,0 +1,66 @@ +import {useAdminQuestionsQuery} from "@/hooks/api/question/useAdminQuestionsQuery"; +import {useCallback, useEffect, useState} from "react"; +import {Question} from "@/types/admin/question"; +import {useQuestionSaveMutation} from "@/hooks/api/question/useQuestionSaveMutation"; + + +export const useQuestionsManage = (questionSetId: number | undefined) => { + const questionSaveMutation = useQuestionSaveMutation(); + const [tempId, setTempId] = useState(-1) + const {questions, isLoading, refetch} = useAdminQuestionsQuery(questionSetId) + const [newRows, setNewRows] = useState([]); + + const handlePrependRow = useCallback(() => { + if(questionSetId === undefined){ + return ; + } + setNewRows(rows => [{questionId: tempId, sequence: 1, question: "", questionSetId: questionSetId}, ...rows]) + setTempId(t => t - 1); + }, [newRows,tempId]); + + + const handlePrependRows = useCallback((questions: string[]) => { + if(questionSetId === undefined){ + return ; + } + + setNewRows(rows => [ + ...questions.map((question, sequence) => ({questionSetId, sequence: sequence + 1, question, questionId: tempId - sequence}) as Question), + ...rows + ]) + + setTempId(t => t + -questions.length); + + }, [questionSetId, newRows, tempId]); + + const handleRemove = useCallback((id: number) => { + console.log(id); + }, [newRows]) + + const handleSave = useCallback(() => { + newRows.forEach((question, index, arr) => questionSaveMutation.mutate(question, {onSuccess: () => { + if(index === arr.length - 1) { + setNewRows([]) + refetch(); + } + }})) + }, [newRows]); + + useEffect(() => { + setNewRows([]); + } , [questionSetId]) + + useEffect(() => { + }, [questions]); + + const rows = [...newRows, ...questions]; + + return { + questions: rows, + handlePrependRow, + handlePrependRows, + handleRemove, + handleSave, + isLoading + } +} diff --git a/frontend/src/pages/QuestionSetManagePage.tsx b/frontend/src/pages/QuestionSetManagePage.tsx new file mode 100644 index 0000000..144c947 --- /dev/null +++ b/frontend/src/pages/QuestionSetManagePage.tsx @@ -0,0 +1,58 @@ +import {CSSProperties, PropsWithChildren} from "react"; +import AdminEditorButton from "@/components/AdminEditorButton/AdminEditorButton"; +import QuestionSetTable from "@/components/QuestionSetTable/QuestionSetTable"; +import useQuestionSetManage from "@/pages/QuestionSetManage/useQuestionSetManage"; +import QuestionSetPreview from "@/components/QuestionSetPreview/QuestionSetPreview"; +import QuestionSetEditForm from "@/components/QuestionSetTable/QuestionSetEditForm"; +import {Modal, useDisclosure} from "@nextui-org/react"; +import {useQuestionSetLoader} from "@/pages/QuestionSetManage/useQuestionSetLoader"; +import QuestionsTable from "@/components/QuestionsTable/QuestionsTable"; +import {useQuestionsManage} from "@/pages/QuestionSetManage/useQuestionsManage"; +import TextPreview from "@/components/TextPreview/TextPreview"; + +const border = "1px solid rgb(54, 54, 54)"; + +const QuestionSetManagePage = () => { + const {isOpen, onOpen, onClose} = useDisclosure(); + const {isOpen: textPreviewIsOpen, onOpen: textPreviewOpen, onClose: textPreviewClose} = useDisclosure(); + const {questionSetList, pageInfo,selected} = useQuestionSetLoader(); + const {rows: questionSetRows, form, updateInputValue, handleRegisterNewQuestionSet} = useQuestionSetManage(questionSetList ?? []); + + const {questions, isLoading, handlePrependRows, handleSave} = useQuestionsManage(selected.selectedQuestionSetId); + + return <> +
+ + + + + + + + + + +
+ + + + + + + + + +} + + +const Box = ({children, style}: PropsWithChildren & {style?: CSSProperties} ) => { + return( +
+ {children} +
+ ) +} + +export default QuestionSetManagePage; + + diff --git a/frontend/src/router/AdminLayout.tsx b/frontend/src/router/AdminLayout.tsx new file mode 100644 index 0000000..5b0abc1 --- /dev/null +++ b/frontend/src/router/AdminLayout.tsx @@ -0,0 +1,16 @@ +import {ErrorBoundary} from "react-error-boundary"; +import ErrorFallback from "@/components/ErrorFallback/ErrorFallback"; +import {ScrollShadow} from "@nextui-org/react"; +import {Outlet} from "react-router-dom"; + +const AdminLayout = () => { + return ( + + + + + + ) +} + +export default AdminLayout; diff --git a/frontend/src/router/Router.tsx b/frontend/src/router/Router.tsx index f8359a8..6eff056 100644 --- a/frontend/src/router/Router.tsx +++ b/frontend/src/router/Router.tsx @@ -8,8 +8,10 @@ import InterviewHistoryPage from "@/pages/InterviewHistoryPage"; import {lazy, Suspense} from "react"; import InterviewResultPage from "@/pages/InterviewResultPage"; import NotFoundPage from "@/pages/NotFoundPage"; +import QuestionSetManagePage from "@/pages/QuestionSetManagePage"; +const AdminLayout = lazy(() => import("@/router/AdminLayout")); const DefaultLayout = lazy(() => import("@/router/DefaultLayout")); const LoginLayout = lazy(() => import("@/router/LoginLayout")); const InterviewLayout = lazy( () => import("@/router/InterviewLayout")); @@ -59,6 +61,13 @@ function Router() { children: [ {index: true, element: } ] + }, + { + path: '/admin', + element: }>, + children: [ + {path: '/admin/manage/question-set', element: }, + ] } ]) diff --git a/frontend/src/types/admin/question.ts b/frontend/src/types/admin/question.ts new file mode 100644 index 0000000..46f5ed6 --- /dev/null +++ b/frontend/src/types/admin/question.ts @@ -0,0 +1,20 @@ + +export interface Question { + questionId: number; + questionSetId: number; + question: string; + sequence: number; +} + + +export interface QuestionSearchResponse { + questions: Question[] +} + + +export interface QuestionRow{ + questionId: number; + questionSetId: number; + question: string; + sequence: number; +} diff --git a/frontend/src/types/admin/questionSet.ts b/frontend/src/types/admin/questionSet.ts new file mode 100644 index 0000000..fd2576a --- /dev/null +++ b/frontend/src/types/admin/questionSet.ts @@ -0,0 +1,24 @@ +import {PageResponse} from "@/types/api"; + +export interface QuestionSet { + questionSetId: number; + thumbnailUrl: string; + title: string; + description: string; + defaultTailQuestionDepth: number; +} + +export interface QuestionSetRow { + questionSetId: number; + thumbnailUrl: string; + title: string; + description: string; + defaultTailQuestionDepth:number; +} + +export type QuestionSetSaveBody = Omit + + + +export interface QuestionSetResponse extends PageResponse{ +} diff --git a/frontend/src/types/question.ts b/frontend/src/types/question.ts index eeb4d46..22c8c12 100644 --- a/frontend/src/types/question.ts +++ b/frontend/src/types/question.ts @@ -32,3 +32,4 @@ export interface Chat { content: string; } + diff --git a/frontend/src/types/questionSet.ts b/frontend/src/types/questionSet.ts index b70ba42..2c29f9d 100644 --- a/frontend/src/types/questionSet.ts +++ b/frontend/src/types/questionSet.ts @@ -19,7 +19,7 @@ export interface QuestionSetSearchResponse { */ count: number; - thumbnailUrl: string | null; + thumbnailUrl: string; tailQuestionDepth: number; } @@ -31,6 +31,7 @@ export interface QuestionSet { description: string; count: number; tailQuestionDepth: number; - - thumbnailUrl: string | null; + thumbnailUrl: string; } + +