Frond-end/React

[Next.js] 게시판 화면, 상세조회, 팝업 컴포넌트 개발

Nellie Kim 2024. 2. 15. 15:12
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;

 


 

 

아래 화면이 생성되었다.