728x90
react 11.1.2 , next.js 17.0.2 로 개발한 게시판 화면이다.
1. list.tsx
문의 목록 게시판
import React, { useState, useEffect } from "react";
import Layout from "example/containers/Layout";
import { getAdminInquiryList, InfoModel, Api } from "../../../api/inquiryApi";
import { useRouter } from "next/router";
import DatePicker from "react-datepicker";
import { format } from "date-fns";
import "react-datepicker/dist/react-datepicker.css";
import {
Label,
Input,
Button,
WindmillContext,
} from "@roketid/windmill-react-ui";
import {
TableBody,
TableContainer,
Table,
TableHeader,
TableCell,
TableRow,
TableFooter,
Avatar,
Badge,
Pagination,
} from "@roketid/windmill-react-ui";
import Link from "next/link";
function InquiryPage() {
const [page, setPage] = useState(1);
const [totalResults, setTotalResults] = useState(0);
const [searchStartDt, setSearchStartDt] = useState(() => {
const today = new Date();
today.setMonth(today.getMonth() - 3);
return today;
});
const [searchEndDt, setSearchEndDt] = useState(new Date());
const [keyword, setKeyword] = useState("");
const [storeName, setStoreName] = useState("");
const router = useRouter();
// pagination setup
const resultsPerPage = 15;
const [data, setData] = useState<InfoModel[]>([]);
// 문의 상세조회 페이지로 이동
const moveInquiryDetail = (idx: number) => {
router.push(`/parkgolf/ceo/inquiry/${idx}`);
};
// 목록 조회로 이동
const moveInquiryList = () => {
router.push(`/parkgolf/ceo/inquiry/list/`);
};
// 목록 조회 (대기) 로 이동
const moveInquiryListWait = () => {
router.push(`/parkgolf/ceo/inquiry/listWait/`);
};
// 목록 조회 (완료) 로 이동
const moveInquiryListCmpt = () => {
router.push(`/parkgolf/ceo/inquiry/listCmpt/`);
};
// 서버에서 데이터 호출
async function fetchData() {
const request = {
keyword: keyword,
searchStartDt: format(searchStartDt, "yyyy-MM-dd HH:mm:ss"),
searchEndDt: format(searchEndDt, "yyyy-MM-dd HH:mm:ss"),
storeName: storeName,
pSize: resultsPerPage,
pNum: page,
};
const response = await getAdminInquiryList(request);
console.log("response", response);
if (response.status == 200) {
// 성공하면 데이터 세팅
setTotalResults(response.data.totalCount);
response.data.dataList.forEach((item: InfoModel) => {
item.formattedCrteDt = format(item.crteDt as Date, "yyyy.MM.dd HH:mm");
});
setData(response.data.dataList);
}
}
function onPageChange(page: number) {
setPage(page);
}
// 페이지가 변경될 때마다 fetchData 호출
useEffect(() => {
fetchData();
}, [page]);
return (
<Layout>
<div className="content">
<div className="box">
<div className="page-title">
1:1문의
<div className="page-tab-wrap">
<a
href="javascript:void(0);"
className="btn btn-border-green"
onClick={() => moveInquiryList()}
>
전체보기
</a>
<a
href="javascript:void(0);"
className="btn btn-border"
onClick={() => moveInquiryListWait()}
>
대기
</a>
<a
href="javascript:void(0);"
className="btn btn-border"
onClick={() => moveInquiryListCmpt()}
>
답변 완료
</a>
</div>
</div>
<div className="sch-wrap">
<div className="datepicker-wrap">
<span>기간</span>
<div className="datepicker">
<DatePicker
selected={searchStartDt}
shouldCloseOnSelect
onChange={(date: Date) => setSearchStartDt(date)}
dateFormat="yyyy-MM-dd"
id="datepicker1"
/>
</div>
<span>~</span>
<div className="datepicker">
<DatePicker
selected={searchEndDt}
onChange={(date: Date) => setSearchEndDt(date)}
dateFormat="yyyy-MM-dd"
id="datepicker2"
/>
</div>
</div>
<div className="input-btn-group">
<span>매장명</span>
<Input
id="storeName"
type="text"
value={storeName}
onChange={(e) => setStoreName(e.target.value)}
/>
<span>제목+내용</span>
<Input
id="keyword"
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button onClick={fetchData} className="btn btn-border">
검색
</Button>
</div>
</div>
<TableContainer>
<Table>
<colgroup>
<col style={{ width: "50px" }} />
<col style={{ width: "150px" }} />
<col style={{ width: "270px" }} />
<col style={{ width: "250px" }} />
<col style={{ width: "180px" }} />
</colgroup>
<TableHeader>
<tr>
<TableCell>
<span className="title">No</span>
</TableCell>
<TableCell>
<span className="title">상태</span>
</TableCell>
<TableCell>
<span className="title">제목</span>
</TableCell>
<TableCell>
<span className="title">매장명</span>
</TableCell>
<TableCell>
<span className="title">문의 일시</span>
</TableCell>
</tr>
</TableHeader>
<TableBody>
{data.map((list, i) => (
<TableRow
key={i}
onClick={() => moveInquiryDetail(list.idx as number)}
>
<TableCell>
<span className="">{(page - 1) * 10 + i}</span>
</TableCell>
<TableCell>
<span
className={
list.statusCd === "ANSWER_WAIT" ? "" : "blue-text"
}
>
{list.statusCd === "ANSWER_WAIT" ? "대기" : "답변 완료"}
</span>
</TableCell>
<TableCell>
<span className="">{list.contents}</span>
</TableCell>
<TableCell>
<span className="">{list.storeName}</span>
</TableCell>
<TableCell>
<span className="">{list.formattedCrteDt}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TableFooter>
<Pagination
totalResults={totalResults}
resultsPerPage={resultsPerPage}
label="Table navigation"
onChange={onPageChange}
/>
</TableFooter>
</TableContainer>
</div>
</div>
</Layout>
);
}
export default InquiryPage;
2. [idx].tsx
글 상세 조회 페이지
답글 등록, 삭제, 목록으로 가기 버튼이 있다.
/* eslint-disable @next/next/no-img-element */
import React, { useState, useRef, useEffect } from "react";
import { useRouter } from "next/router";
import {
getAdminInquiry,
regAdminInquiry,
delAdminInquiry,
InfoModel,
Api,
} from "../../../api/inquiryApi";
import Layout from "example/containers/Layout";
import { format } from "date-fns";
import Popup from "../component/popup";
import Image from "next/image";
function DetailPage() {
/**
|--------------------------------------------------
| 😃 1. 일반 변수
|--------------------------------------------------
*/
const router = useRouter();
const { idx } = router.query; // URL에서 idx 매개변수에 액세스
const [data, setData] = useState<InfoModel>(); // 상세조회 data
const [contents, setContents] = useState<string>(""); // 문의 내용
const [contentsRpl, setContentsRpl] = useState<string>(""); // 답변 내용
const [isPopupOpenReg, setIsPopupOpenReg] = useState<boolean>(false); // 답변 완료 팝업창 변수
const [isPopupOpenReg2, setIsPopupOpenReg2] = useState<boolean>(false); // 답변 완료 팝업창2 변수
const [isPopupOpenDel, setIsPopupOpenDel] = useState<boolean>(false); // 삭제 팝업창2 변수
const [isPopupOpenDel2, setIsPopupOpenDel2] = useState<boolean>(false); // 삭제 팝업창2 변수
/**
|--------------------------------------------------
| 🔑 2. 일반 함수
|--------------------------------------------------
*/
/** 2-1. 답변 입력 시 contents 변수 변경 */
const handleContentsChange = (e: any) => {
setContentsRpl(e.target.value);
};
/** 2-2. 답변 완료 버튼 클릭 시 - 팝업창 1 열기 */
const handleRegRpl = () => {
if (!contentsRpl) {
alert("답변을 작성해주세요.");
return;
} else {
setIsPopupOpenReg(true);
}
};
/** 2-3. 답변 완료 '팝업창' 확인 클릭 시 - 팝업창2 열기*/
const handleRegRplCmpt = () => {
// 팝업창1 닫기
setIsPopupOpenReg(false);
// 서버에 답변 post하고 상세페이지 다시 로드
fetchDataReg(Number(idx), contentsRpl);
// '답변이 완료되었습니다' 팝업창 열기
setIsPopupOpenReg2(true);
};
/** 2-4. '질문 삭제' 클릭 시 - 팝업창1 열기 */
const handleDel = () => {
setIsPopupOpenDel(true);
};
/** 2-5. 질문 삭제 '팝업창' 확인 클릭 시 - 팝업창2 열기 */
const handleDelCmpt = () => {
// 팝업창 닫기
setIsPopupOpenDel(false);
// 서버에서 글 삭제만 (라우팅x)
fetchDataDel(Number(idx));
// '삭제가 완료되었습니다' 팝업창 열기
setIsPopupOpenDel2(true);
};
/** 2-6. 삭제 팝업창2에서 확인 클릭 시, 팝업창2 닫고 목록으로 라우팅 */
const handleCancelDel = () => {
setIsPopupOpenDel2(false);
moveInquiryList();
};
/** 2-7. 모달창 닫기(확인 또는 취소) 클릭 시 */
const handleCancel = () => {
setIsPopupOpenReg(false);
setIsPopupOpenReg2(false);
setIsPopupOpenDel(false);
};
/**
|--------------------------------------------------
| 🎁 3. fetch 함수
|--------------------------------------------------
*/
/** 3-1. 서버에서 1:1문의 목록 가져오기 */
async function fetchData(idx: any) {
const request = {
idx: Number(idx),
};
const response = await getAdminInquiry(request);
if (response.status == 200) {
// 성공하면 데이터 세팅..
response.data.formattedCrteDt = format(
response.data.crteDt as Date,
"yyyy.MM.dd HH:mm"
);
// 개행 문자열 처리
if (response.data.inquiryRplDtoList[0]?.contents !== undefined) {
response.data.inquiryRplDtoList[0].contents =
response.data.inquiryRplDtoList[0].contents.replace(/\n/g, "<br>");
}
setData(response.data);
}
}
/** 3-2. 서버에 답변 post */
async function fetchDataReg(idx: number, contentsRpl: string) {
const infoModel: InfoModel = {
parentIdx: idx,
contents: contentsRpl,
};
const response = await regAdminInquiry(infoModel);
if (response.status == 200) {
fetchData(idx);
}
}
/**3-3. 게시글 삭제 */
async function fetchDataDel(idx: number) {
const infoModel: InfoModel = {
idx: Number(idx),
};
const response = await delAdminInquiry(infoModel);
if (response.status !== 200) {
alert("삭제에 실패했습니다.");
}
}
/**
|--------------------------------------------------
| 🚗 4. 라우팅 함수
|--------------------------------------------------
*/
/** 문의 상세조회 페이지로 이동 */
const moveInquiryDetail = (idx: number) => {
router.push(`${idx}`);
// router.push(`/parkgolf/ceo/inquiry/${idx}`);
};
/** 목록으로 이동 */
const moveInquiryList = () => {
router.push("/parkgolf/ceo/inquiry/list/");
};
/**
|--------------------------------------------------
| ⚡ 5. useEffect
|--------------------------------------------------
*/
/** 목록 조회 GET */
useEffect(() => {
fetchData(idx);
}, [idx]); // idx가 변경될 때마다 fetchData() 호출
/** 데이터 받아올 때마다 개행문자열 찾아서 개행하여 노출 */
useEffect(() => {
if (data?.contents !== undefined) {
const formattedContents = data.contents.replace(/\n/g, "<br>");
setContents(formattedContents);
}
}, [data]);
/*
|--------------------------------------------------
| 🎨 화면
|--------------------------------------------------
*/
return (
<Layout>
<div className="content">
<div className="box">
<div className="page-title">1:1문의</div>
{data && ( // data가 존재할 때만 렌더링
<table className="white-table left-table">
<colgroup>
<col style={{ width: "150px" }} />
<col />
</colgroup>
<tbody>
<tr>
<th>상태</th>
<td>
<span
className={
data.statusCd === "ANSWER_WAIT" ? "" : "blue-text"
}
>
{data.statusCd === "ANSWER_WAIT" ? "대기" : "답변 완료"}
</span>
</td>
</tr>
<tr>
<th>제목</th>
<td>{data.title}</td>
</tr>
<tr>
<th>내용</th>
<td>{contents}</td>
</tr>
<tr>
<th>사진</th>
<td>
<ul className="image-preview">
<li>
<a href="javascript:void(0);" className="img-box">
<img
src={data.apndFileList?.[0]?.urlPath}
alt=""
width={200}
height={200}
/>
</a>
</li>
<li>
<a href="javascript:void(0);" className="img-box">
<img
src="https://via.placeholder.com/200x300"
alt="Placeholder"
width={200}
height={300}
/>
</a>
</li>
<li>
<a href="javascript:void(0);" className="img-box">
<img
src="https://via.placeholder.com/300x200"
alt="Placeholder"
width={300}
height={200}
/>
</a>
</li>
</ul>
</td>
</tr>
<tr>
<th>매장명</th>
<td>
{data.storeName}
<a href=""> [매장정보 바로가기]</a>
</td>
</tr>
<tr>
<th>문의 일시</th>
<td>{data.formattedCrteDt}</td>
</tr>
</tbody>
</table>
)}
{data &&
data.inquiryRplDtoList &&
data.inquiryRplDtoList.length > 0 ? (
<>
<div className="page-title mt50">답변</div>
<table className="white-table left-table">
<colgroup>
<col style={{ width: "150px" }} />
<col />
</colgroup>
<tbody>
<tr>
<th>내용</th>
{/* <td>{data.inquiryRplDtoList[0].contents}</td> 개행 되도록 아래 코드로 수정*/}
{data.inquiryRplDtoList[0]?.contents ? (
<td
dangerouslySetInnerHTML={{
__html: data.inquiryRplDtoList[0].contents,
}}
></td>
) : null}
</tr>
<tr>
<th>답변 일시</th>
<td>{data.inquiryRplDtoList[0].formattedCrteDt}</td>
</tr>
</tbody>
</table>
</>
) : (
<>
<div className="page-title mt50">답변</div>
<table className="white-table left-table">
<colgroup>
<col style={{ width: "150px" }} />
<col />
</colgroup>
<tbody>
<tr>
<th>내용</th>
<td>
<textarea
cols={30}
rows={10}
value={contentsRpl}
onChange={handleContentsChange}
></textarea>
</td>
</tr>
</tbody>
</table>
</>
)}
<div className="btn-wrap sb-type">
<a className="btn btn-border btn-submit" onClick={moveInquiryList}>
목록
</a>
<a className="btn btn-border btn-submit" onClick={handleRegRpl}>
답변 완료
</a>
{isPopupOpenReg && (
<Popup
isOpen={isPopupOpenReg}
message="답변을 완료 하시겠습니까?<br><br>완료한 답변은 수정이 불가능합니다."
onConfirm={handleRegRplCmpt}
onCancel={handleCancel}
/>
)}
{isPopupOpenReg2 && (
<Popup
isOpen={isPopupOpenReg2}
message="답변이 완료되었습니다."
onConfirm={handleCancel}
onCancel={() => {}}
/>
)}
<a className="btn btn-gray btn-submit" onClick={handleDel}>
질문 삭제
</a>
{isPopupOpenDel && (
<Popup
isOpen={isPopupOpenDel}
message="질문을 삭제 하시겠습니까?<br><br>삭제 시 복구가 불가능합니다."
onConfirm={handleDelCmpt}
onCancel={handleCancel}
/>
)}
{isPopupOpenDel2 && (
<Popup
isOpen={isPopupOpenDel2}
message="삭제 되었습니다."
onConfirm={handleCancelDel}
onCancel={() => {}}
/>
)}
</div>
</div>
</div>
</Layout>
);
}
export default DetailPage;
3. popup.tsx
팝업 컴포넌트를 따로 생성함
import React from "react";
interface PopupProps {
isOpen: boolean;
message: string; // 모달창 내 삽입할 메시지
onConfirm: () => void; // 확인 클릭 시 동작하는 함수
onCancel?: () => void; // 취소 클릭 시 동작하는 함수 (옵션)
}
const Popup: React.FC<PopupProps> = ({
isOpen,
message,
onConfirm,
onCancel,
}) => {
return (
<div className={`popup-wrap ${isOpen ? "active" : ""}`}>
<div className="popup-box">
<div className="popup-cont">
<div
className="message"
dangerouslySetInnerHTML={{ __html: message }}
></div>
</div>
<ul className="submit-btn">
<li>
<button className="btn btn-green" onClick={onConfirm}>
확인
</button>
</li>
{onCancel && ( // onCancel이 정의되어 있을 때만 취소 버튼을 렌더링
<li>
<button
className="btn btn-gray popup-cancel-btn"
onClick={onCancel}
>
취소
</button>
</li>
)}
</ul>
</div>
</div>
);
};
export default Popup;
아래 화면이 생성되었다.
'Frond-end > React' 카테고리의 다른 글
[React] 글과 이미지가 같이 있는 글 저장해서 보여주는 방법 (0) | 2024.03.02 |
---|---|
[React] 좋아요 적용 (0) | 2024.02.27 |
[React] 리액트에서 텍스트 줄바꿈(개행)하는 방법 (whiteSpace: "pre-wrap") (0) | 2024.02.26 |
[React] datepicker 적용해보기 (0) | 2024.02.21 |
VScode 단축키 총정리 (0) | 2024.02.04 |