From 1d62cdaccfdb57a63a527dc2a5e062fea1cbf8d6 Mon Sep 17 00:00:00 2001 From: DoubleD <53948297+Hoon-Hub@users.noreply.github.com> Date: Sun, 20 Aug 2023 02:24:56 +0900 Subject: [PATCH] Shkim0819 (#104) --- index.d.ts | 3 +- package.json | 3 + src/assets/components/etc/popup.css | 108 +++++++++++++ .../components/etc/showNewQuestions.css | 58 ++++++- src/components/ETCUpdatePopup.tsx | 92 +++++++++++ src/composables/ETC/index.ts | 67 ++++++++ src/pages/etc/ShowNewQuestions.tsx | 152 +++++++++++++++--- src/store/modules/ETC_QuestionList.ts | 82 ++++++++-- 8 files changed, 520 insertions(+), 45 deletions(-) create mode 100644 src/assets/components/etc/popup.css create mode 100644 src/components/ETCUpdatePopup.tsx create mode 100644 src/composables/ETC/index.ts diff --git a/index.d.ts b/index.d.ts index 2ddbe46..ba78852 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,5 @@ declare module "*.ttf"; declare module "*.png" declare module "*.svg" -declare module "react-moment" \ No newline at end of file +declare module "react-moment" +declare module "file-saver" \ No newline at end of file diff --git a/package.json b/package.json index 9d3da0e..4f35bb5 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,15 @@ "@types/styled-components": "^5.1.26", "axios": "^1.3.4", "downloadjs": "^1.4.7", + "exceljs": "^4.3.0", + "file-saver": "^2.0.5", "framer-motion": "^10.10.0", "html2canvas": "^1.4.1", "react": "^18.2.0", "react-calendar": "^4.2.1", "react-custom-scrollbars-2": "^4.5.0", "react-dom": "^18.2.0", + "react-intersection-observer": "^9.5.2", "react-masonry-css": "^1.0.16", "react-moment": "^1.1.3", "react-router-dom": "^6.8.2", diff --git a/src/assets/components/etc/popup.css b/src/assets/components/etc/popup.css new file mode 100644 index 0000000..f283c88 --- /dev/null +++ b/src/assets/components/etc/popup.css @@ -0,0 +1,108 @@ +.popup { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(49, 41, 41, 0.8); + z-index: 9999; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 0 20px; + box-sizing: border-box; + overflow: hidden; + transition: all 0.3s ease-in-out; +} + +.popup-inner { + position: relative; + width: 100%; + max-width: 600px; + padding: 40px; + box-sizing: border-box; + background-color: #fff; + box-shadow: 0px 3px 3px -2px rgba(0,0,0,0.2); + border-radius: 10px; +} +.popup-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; +} +.close-btn { + width: 30px; + height: 30px; + border-radius: 50%; + border: none; + background-color: #fff; + cursor: pointer; + outline: none; + font-weight: bold; +} +.popup-body{ + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} +.popup-body-inner{ + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: left; + flex-direction: column; +} +.popup-body-inner-left{ + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 12px; +} +.popup-body-inner-body { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 4px; +} +.popup-body-inner-body label { + font-size: 0.8rem; + font-weight: bold; + margin-bottom: 4px; +} +.popup-body-inner-body input, .popup-body-inner-body select { + width: 100%; + height: 40px; + border: 1px solid #E0E0E0; + border-radius: 5px; + padding: 10px; +} +.popup-footer { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 20px; + margin-bottom: 55px; +} +.popup-footer-btn { + width: 100%; + height: 40px; + border: none; + border-radius: 5px; + font-weight: bold; + cursor: pointer; + outline: none; + transition: all 0.2s ease-in-out; +} +.popup-footer-btn:hover { + background-color: #E0E0E0; +} diff --git a/src/assets/components/etc/showNewQuestions.css b/src/assets/components/etc/showNewQuestions.css index 6ae289e..3aeb5fa 100644 --- a/src/assets/components/etc/showNewQuestions.css +++ b/src/assets/components/etc/showNewQuestions.css @@ -3,7 +3,7 @@ top:0; left: 0; width:100%; - height: 100%; + min-height: 100vh; padding: 20px; background-color: #FAF9F6; } @@ -27,12 +27,24 @@ /* 검색영역 */ +.s-n-q-search-info { + width: 100%; + font-size: 0.9rem; +} + .s-n-q-search-area { width: 100%; display: flex; flex-direction: row; align-items: center; + padding-top: 20px; + margin-bottom: 20px; gap: 4px; + position: sticky; + top: 0; + background-color: #FAF9F6; + /* 아래에만 sticky 적용이 될 때 그림자 적용 */ + box-shadow: 0px 3px 3px -2px rgba(0,0,0,0.2); } .s-n-q-search-area > select { @@ -62,6 +74,11 @@ } /* 목록영역 */ +.s-n-q-count { + font-size: 0.8rem; + margin-bottom: 1rem; +} + .s-n-q-list { width: 100%; display: flex; @@ -70,6 +87,30 @@ } .s-n-q-list-header { + width: 100%; + height: 30px; + display: flex; + flex-direction: row; + align-items: center; + padding: 4px; + text-align: center; + font-size: 0.9rem; +} +.s-n-q-list-header tr { + width: 100%; + display: flex; + gap: 2px; +} + +.s-n-q-list-item-area { + width: 100%; + display: flex; + flex-direction: column; + gap: 5px; +} + +.s-n-q-list-item { + font-size: 0.7rem; width: 100%; height: 40px; display: flex; @@ -78,4 +119,19 @@ gap: 2px; padding: 4px; text-align: center; + border: 1px solid #E0E0E0; + border-radius: 5px; +} + +.s-n-q-list-item:hover { + font-weight: bold; +} +.snq-item-edit-btn { + height: 40px; + line-height: 40px; + cursor: pointer; +} +.snq-item-edit-btn:hover { + cursor: pointer; + text-decoration: underline; } \ No newline at end of file diff --git a/src/components/ETCUpdatePopup.tsx b/src/components/ETCUpdatePopup.tsx new file mode 100644 index 0000000..ae36137 --- /dev/null +++ b/src/components/ETCUpdatePopup.tsx @@ -0,0 +1,92 @@ +import {useState} from 'react' +import useETCQuestionStore, { ETC_QS_TYPE } from "store/modules/ETC_QuestionList" +import 'assets/components/etc/popup.css' + +/** + * @desc 수정할 질문 내용을 입력하는 팝업창 + * @param {Objcet} props + */ +const ETCUpdatePopup = ({updateActive, setUpdateActive}:PROPS ) => { + const {updateQuestion} = useETCQuestionStore() + const [q, setQ] = useState(updateActive.qquestion) + const [w, setW] = useState(updateActive.qwriter) + const [c, setC] = useState(updateActive.qcategory) + + //CLOSE POPUP + const close = () => { + setUpdateActive({qno:0, qquestion:"", qwriter:"", qcategory:"", qcreatedAt:""}) + } + + const handleQ = (e:React.ChangeEvent) => setQ(e.target.value) + const handleW = (e:React.ChangeEvent) => setW(e.target.value) + const handleC = (e:React.ChangeEvent) => setC(e.target.value) + + //update question + const uq = async () => { + if (q === "" || w === "" || c === "") { + alert("모든 항목을 입력해주세요.") + } else { + if (updateActive.qcategory === c && updateActive.qquestion === q && updateActive.qwriter === w) { + console.log('변경사항이 없습니다.'); + close() + } else { + const param = { + qno: updateActive.qno, + qquestion: q, + qwriter: w, + qcategory: c, + qcreatedAt: updateActive.qcreatedAt + } + await updateQuestion(param) + close() + } + } + } + + return ( +
+
+
+

질문 수정

+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + ) +} + +type PROPS = { + updateActive: ETC_QS_TYPE + setUpdateActive: React.Dispatch> +} + +export default ETCUpdatePopup \ No newline at end of file diff --git a/src/composables/ETC/index.ts b/src/composables/ETC/index.ts new file mode 100644 index 0000000..29e8495 --- /dev/null +++ b/src/composables/ETC/index.ts @@ -0,0 +1,67 @@ +import { saveAs } from 'file-saver'; +import Excel from 'exceljs'; + + +export const colums:Array = [ + {header: 'No', key: 'qno', width: 7, alignment: 'center'}, + {header: '질문', key: 'qquestion', width: 125, alignment: 'left'}, + {header: '작성자', key: 'qwriter', width: 25, alignment: 'center'}, + {header: '카테고리', key: 'qcategory', width: 10, alignment: 'center'}, + {header: '작성일자', key: 'qcreatedAt', width: 23, alignment: 'center'}, +] + +export const exportToExcel = async (search: string, list:Array) => { + const workSheetName = '질문목록탭'; + const workBookName = '추가한 질문 목록'; + const workbook = new Excel.Workbook(); + try { + console.log('exportExcel') + const fileName = search.length > 0 ? search : workBookName; + const worksheet = workbook.addWorksheet(workSheetName); + + //@ts-ignore + worksheet.columns = colums; + worksheet.getRow(1).font = { bold: true }; + + worksheet.columns.forEach((column:any)=> { + column.width = column.width; + column.alignment = { horizontal: 'left' }; + column.font = { name: '굴림', size: 10 }; + }); + + //현재 sorting 되어져 있는 목록을 excel 에 출력 + list.forEach(singleData => { + worksheet.addRow(singleData); + }); + + worksheet.eachRow({ includeEmpty: false }, row => { + //@ts-ignore + const currentCell = row._cells; + //@ts-ignore + currentCell.forEach(singleCell => { + const cellAddress = singleCell._address; + // apply border + worksheet.getCell(cellAddress).border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + const buf = await workbook.xlsx.writeBuffer(); + saveAs(new Blob([buf]), `${fileName}.xlsx`); + + } catch (error) { + console.log(error) + } +} + + +export type COLUMS = { + header: string, + key: string, + width?: number, + alignment?: string +} \ No newline at end of file diff --git a/src/pages/etc/ShowNewQuestions.tsx b/src/pages/etc/ShowNewQuestions.tsx index 8ed8e1f..f5a6676 100644 --- a/src/pages/etc/ShowNewQuestions.tsx +++ b/src/pages/etc/ShowNewQuestions.tsx @@ -1,8 +1,11 @@ import 'assets/components/etc/showNewQuestions.css' import export_svg from 'assets/images/etc/export_svg.svg' import reload_svg from 'assets/images/etc/reload.svg' -import { useEffect } from 'react' -import useETCQuestionStore, {SEARCH} from 'store/modules/ETC_QuestionList' +import { useEffect, useMemo, useState, useCallback } from 'react' +import useETCQuestionStore, { ETC_QS_TYPE, SEARCH } from 'store/modules/ETC_QuestionList' +import {exportToExcel} from 'composables/ETC' +import { useInView } from 'react-intersection-observer'; +import ETCUpdatePopup from 'components/ETCUpdatePopup' /** * @desc 추가한 질문 목록 조회 [운영자-확인용] @@ -16,33 +19,105 @@ import useETCQuestionStore, {SEARCH} from 'store/modules/ETC_QuestionList' * 3. sort */ const ShowNewQuestions = () => { - const {getETCQuestionList} = useETCQuestionStore() + const {getETCQuestionList, sortedQuestionList, getSortedQuestionList, deleteQuestion} = useETCQuestionStore() + const [search, setSearch] = useState("") //검색어 + const [isSearched, setIsSearched] = useState(false) //검색여부 + const [itemCount, setItemCount] = useState(30) //출력개수 + const [page, setPage] = useState(1) //페이지네이션 + const [size, setSize] = useState(30) //페이지네이션 + const [ref, inView] = useInView(); //페이지네이션-imported + const [updateActive, setUpdateActive] = useState({qno:0, qquestion:'', qcategory:'', qwriter: '', qcreatedAt:''}) //수정모드 + + //useMemo를 사용하여 검색어 입력 시 필터링 + const questionList = useMemo( + () => sortedQuestionList, + [sortedQuestionList] + ); + const hasNextPage = useMemo(()=> questionList.length % size === 0, [questionList]) //페이지네이션 + + //검색단어 입력 시 목록 필터링 + const filterSortList = (e:React.ChangeEvent) => { + e.preventDefault(); + setSearch(e.target.value) + if (e.target.value !== "") { + setIsSearched(true) + } else { + setIsSearched(false) + } + getSortedQuestionList(e.target.value) + } + + //출력개수 변경 시 + const handleItemCount = (e:React.ChangeEvent) => { + setItemCount(Number(e.target.value)) + setSearch("") + getETCQuestionList({page:1, size:Number(e.target.value)}) + } + //reset 버튼 클릭 이벤트 const resetFilters = () => { - console.log('resetFilters') + setSearch("") + setIsSearched(false) + setItemCount(30) + getETCQuestionList({page:1, size:30}) } - const exportExcel = () => { - console.log('exportExcel') + //목록 삭제 + const deleteItem = async (item:ETC_QS_TYPE) => { + const result = window.confirm("정말로 삭제하시겠습니까? 삭제된 질문은 복구할 수 없습니다.") + result && await deleteQuestion(item.qno) } - useEffect(()=> { - const param:SEARCH = { - pageNo: 1, - pageSize: 30, + //목록 수정 + const updateItem = (item:ETC_QS_TYPE) => { + setUpdateActive(item) + console.log(item); + } + + //엑셀 출력 + const exportExcel = async () => { + exportToExcel(search, questionList) + } + + //페이지네이션 기능 + 호출 + const fetch = useCallback(async (param: SEARCH) => { + try { + await getETCQuestionList(param) + } catch (error) { + console.log(error); + } + }, []) + + + //infinity scroll + useEffect(() => { + if (inView && hasNextPage && !isSearched) { + console.log("다음페이지를 호출."); + const param = {page:page+1, size} + setPage(page+1) + fetch(param); } - getETCQuestionList(param) - }, []) + }, [hasNextPage, inView]); return (
+ { + updateActive.qno !== 0 && ( + ) + }

추가한 질문 목록 조회

{/* 검색영억 */} +

검색할 내용 및 필터링을 설정해주세요. 새로고침을 통해 전체 리스트를 새로 받아올 수 있습니다.

+

목록 추가 조회 시 검색했던 필터링은 사라집니다.

- {/* 검색 */} + {/* 검색 */} - {/* 출력개수 */} @@ -59,22 +134,47 @@ const ShowNewQuestions = () => {
+ {/* 목록영역 */}
-
-
-
No
-
질문
-
작성자
-
작성일자
-
수정
-
삭제
-
-
-
+
+

{questionList.length}개의 질문이 조회되었습니다.

+ + + + + + + + + + + + + + { + questionList.length > 0 ? + questionList.map((item, index) => { + return ( + + + + + + + + + + ) + }) + : + } + +
No질문작성자카테고리작성일자수정삭제
{item.qno}{item.qquestion}{item.qwriter}{item.qcategory}{item.qcreatedAt} updateItem(item)} className='snq-item-edit-btn' style={{width:'40px'}}>수정 deleteItem(item)} className='snq-item-edit-btn' style={{width:'40px'}}>삭제
질문이 존재하지 않아여.
+
-
+ E
) } diff --git a/src/store/modules/ETC_QuestionList.ts b/src/store/modules/ETC_QuestionList.ts index 7f4d970..cf5df8a 100644 --- a/src/store/modules/ETC_QuestionList.ts +++ b/src/store/modules/ETC_QuestionList.ts @@ -1,41 +1,89 @@ import {create, StateCreator} from 'zustand' import { persist, PersistOptions } from "zustand/middleware"; import axios, { AxiosResponse } from "axios"; -import CL from "composables/COMMON/common"; +const useETCQuestionStore = create((set) => ({ + etcQuestionList: Array() || [], //원본데이터 + sortedQuestionList: Array() || [], //sort 등이 적용된 데이터 -const useETCQuestionStore = create(() => ({ - etcQuestionList: [], - + //데이터 조회 로직 getETCQuestionList: async (param: SEARCH) => { - CL.DS("getETCQuestionList") - const res: AxiosResponse = await axios.post(`${process.env.REACT_APP_API_URL}/api/question/getETCQuestionList`, param) + const res: AxiosResponse = await axios.get>(`${process.env.REACT_APP_API_URL}/api/question/questions?page=${param.page}&size=${param.size}`) + const setList = res ? res.data ? res.data.content : [] : [] + if (param.page === 1) { + set({ + etcQuestionList: setList, + sortedQuestionList: setList + }) + } else { + set({ + etcQuestionList: [...useETCQuestionStore.getState().etcQuestionList, ...setList], + sortedQuestionList: [...useETCQuestionStore.getState().etcQuestionList, ...setList] + }) + } + }, + + //검색 sort + getSortedQuestionList: (param: string) => { + const sortedList = [...useETCQuestionStore.getState().etcQuestionList] + if(param !== "") { //검색어 입력 이벤트 + const newSortedList = sortedList.filter((item) => { + if (item.qquestion.toLowerCase().includes(param.toLowerCase()) || item.qwriter.toLowerCase().includes(param.toLowerCase())) { + return item + } + }) + set({sortedQuestionList: newSortedList}) + } else { + set({sortedQuestionList: sortedList}) + } + }, - CL.DE("getETCQuestionList") - } + //삭제 + deleteQuestion: async (param: number) => { + //get 방식으로 qno 전달 + await axios.get(`${process.env.REACT_APP_API_URL}/api/question/delete?qno=${param}`) + //재조회 없이 filtering 처리 (성능상 이점) + const newQuestionList = useETCQuestionStore.getState().etcQuestionList.filter((item) => item.qno !== param) + set({etcQuestionList: newQuestionList, sortedQuestionList: newQuestionList}) + }, - + //수정 + updateQuestion: async (param: ETC_QS_TYPE) => { + await axios.post(`${process.env.REACT_APP_API_URL}/api/question/update`, param) + const newQuestionList = useETCQuestionStore.getState().etcQuestionList.map((item) => { + if(item.qno === param.qno) { + return param + } else { + return item + } + }) + set({etcQuestionList: newQuestionList, sortedQuestionList: newQuestionList}) + } })); type ETC_QS = { etcQuestionList: Array; + sortedQuestionList: Array; getETCQuestionList: (param: SEARCH) => Promise; + getSortedQuestionList: (param: string) => void; + deleteQuestion: (param: number) => Promise; + updateQuestion: (param: ETC_QS_TYPE) => Promise; } -type ETC_QS_TYPE = { - q_no: number; - q_question: string; - q_category: string; - q_writer: string; - q_created_at: string; +export type ETC_QS_TYPE = { + qno: number; + qquestion: string; + qcategory: string; + qwriter: string; + qcreatedAt: string; } export type SEARCH = { - pageNo: number; - pageSize: number; + page: number; + size: number; } export default useETCQuestionStore;