Project Slow-postbox #3 기능 구현(홈, 네비게이션 바, 관리자 페이지)

시작하며

맡은 파트의 목업페이지 구현이 완료되어서 미리 정했던 이슈카드 별로 기능 구현을 시작하였다. 이전 프로젝트와 비슷한 부분도 있고 SR 기획 단계에서 어떤 로직으로 구현을 할지 그려보고 있어서 생각보다 빠른 시간에 끝낼 수 있었다.

페이지 연결

다가치 프로젝트를 진행할 때 놓쳤던 부분이 각 페이지들을 어떻게 연결할 지 미리 정하지 않은 것이였는데, 이번에는 SR 기획 단계에서 미리 정하고 시작해서 수월하게 진행할 수 있었다. 각 페이지들을 react-route-dom을 사용해서 path로 연결할 지, state나 redux를 사용해서 삼항연산자로 연결할 지 기획한 대로 맞춰서 연결하였다.

react-route-dom으로 연결하는 경우에 새로고침을 하면 해당 페이지가 재랜더링 되기 때문에 분리된 기능을 가진 페이지들은 Link를 사용하여 연결하였고, 각 페이지 내에서 변화되는 부분은 state를 활용하였다.

//APP.js render
    <div>
      <NavigationBar isChecked={isChecked} setIsChecked={setIsChecked} />
      <div className='area-nav'></div>
      <Switch>
        <Route exact path='/' component={Home} />
        <Route path='/login' component={Login} />
        <Route path='/signup' component={SignUp} />
        <Route path='/find-userinfo' component={FindUserInfo} />
        <Route
          path='/mailbox'
          render={(props) => (
            <WholeReceivedMail hadleisChecked={hadleisChecked} {...props} />
          )}
        />
        <Route path='/sent-mailbox' component={WholeSentMail} />
        <Route path='/mailform' component={MailForm} />
        <Route path='/mypage' component={MyPage} />
        <Route path='/admin' component={AdminPage} />
      </Switch>
    </div>

네비게이션 바가 상단에 고정되도록 position:fixed 해줘서 다른 컴포넌트에 top-pading을 주는 대신에 <div className='area-nav'>를 만들어서 해당 공간은 침범하지 못하도록 하였다.

네비게이션 바

네비게이션 바에서 구현해야 하는 기능은...

  1. 버튼을 눌렀을 때 해당하는 페이지로 이동
  2. 로그인 여부와 관리자 여부에 따라 해당 하는 버튼이 숨겨짐
  3. 받은 편지함에 확인하지 않은 편지가 있는 경우 빨간색 원으로 표시되는 기능

1번의 경우에는 react-router-dom의 Link를 사용하여 미리 정해진 path로 연결하였고, 2번 기능은 loginReducer에 로그인 여부와, 관리자 여부를 저장해놓기로 정해 놓아서 삼항연산자와 CSS 옵션의 visibilty:hidden를 사용하여 구현하였다.

3번은 서버에서 해당 유저의 편지를 필터링해서 확인하지 않은 메일의 개수를 받아오도록 API를 정하여 axios를 사용해 기능을 구현하였다.

//네비게이션 편지 확인 여부 요청
module.exports = async (req, res) => {
  try {
    const { email } = req.query;
    if (!email) {
      return res.status(404).send();
    }
    const sql =
      'SELECT COUNT(id) AS count FROM mails WHERE (receiverEmail=? AND isChecked=0);';
    const params = [email];
    const [rows, fields, err] = await db.query(sql, params);
    if (err) {
      res.status(404).send();
    } else {
      res.status(200).json({
        isChecked: rows[0]['count'],
      });
    }
  } catch (err) {
    throw err;
  }
};
import React from 'react';
import { Link } from 'react-router-dom';
import { useSelector } from 'react-redux';
import axios from 'axios';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCog } from '@fortawesome/free-solid-svg-icons';
import './NavigationBar.css';


export default function NavigationBar({ isChecked, setIsChecked }) {
  const { email, isLogin, isAdmin } = useSelector((state) => state.loginReducer);
  const handleCheckReceived = () => {
		axios.patch(`${process.env.REACT_APP_SERVER_API}/mail/checked-received`, { email })
		.then((res) => {
		  axios.get(`${process.env.REACT_APP_SERVER_API}/home/checked-mail`, { params: { email } })
      .then((res) => { setIsChecked(res.data.isChecked) })
		})
	  }

  const handleLogout = async () => {
    axios.post(`${process.env.REACT_APP_SERVER_API}/user/logout`, "", { withCredentials: true })
      .then(window.location.replace("/"))
  }


  return (
    <>
      <head>
        <link rel='preconnect' href='https://fonts.googleapis.com' />
        <link rel='preconnect' href='https://fonts.gstatic.com' crossorigin />
        <link
          href='https://fonts.googleapis.com/css2?family=Gaegu:wght@300&display=swap'
          rel='stylesheet'
        />
      </head>
      <div className='navBar-container'>
        <div className='bar'>
          <div
            className='home'
            onClick={() => {
              window.location.replace('/');
            }}
          >
            느린 우체통
          </div>
          <Link
            to='/mailbox'
            style={{ color: 'inherit', textDecoration: 'inherit' }}
            className={
              isLogin ? 'mailBox' : 'mailBox hidden'
            }
            onClick={handleCheckReceived}
          >
            <div className={
              isChecked
                ? 'mailBox noti-on'
                : ''
            }>받은 편지함</div>
          </Link>
          <Link
            to='/sent-mailbox'
            style={{ color: 'inherit', textDecoration: 'inherit' }}
            className={isLogin ? 'sent' : 'sent hidden'}
          >
            <div>보낸 편지함</div>
          </Link>
          <Link
            to='/mailform'
            style={{ color: 'inherit', textDecoration: 'inherit' }}
            className={isLogin ? 'write' : 'write hidden'}
          >
            <div>편지 쓰기</div>
          </Link>
          {isLogin ? (
            <>
              <Link
                to='/mypage'
                style={{ color: 'inherit', textDecoration: 'inherit' }}
                className='mypage'
              >
                <div>마이페이지</div>
              </Link>
              <Link
                to='/login'
                style={{ color: 'inherit', textDecoration: 'inherit' }}
                className='login'
              >
                <div >
                  <span onClick={handleLogout}>로그아웃</span>
                </div>
              </Link>
            </>
          ) : (
            <>
              <Link
                to='/login'
                style={{ color: 'inherit', textDecoration: 'inherit' }}
                className='login'
              >
                <div>
                  <span>로그인</span>
                </div>
              </Link>
              <Link
                to='/signup'
                style={{ color: 'inherit', textDecoration: 'inherit' }}
                className='signup'
              >
                <div>회원가입</div>
              </Link>
            </>
          )}
          {isLogin && isAdmin ? (
            <div className='admin'>
              <Link
                to='/admin'
                style={{ color: 'inherit', textDecoration: 'inherit' }}
              >
                <FontAwesomeIcon icon={faCog} />
              </Link>
            </div>
          ) : (
            ''
          )}
        </div>
      </div>
    </>
  );
}

홈페이지의 경우 목업 단계에서 거의 구현이 완료된 상태이기 때문에 편지쓰기 버튼을 눌렀을때 로그인 여부에 따라 로그인 페이지/편지쓰기 페이지로 이동하도록 Link로 연결하였고 모든 유저의 도착예정 편지의 개수를 받아오는 요청을 통해 구현을 완료하였다.

관리자 페이지

맡은 파트 중에 가장 기능이 많은 페이지였는데 이전 다가치 프로젝트에서 더 많은 필터링 옵션과 검색 기능이 있는 게시판을 구현했기 때문에 헤매지 않고 금방 구현할 수 있었다. 페이지네이션의 경우 react-pagination이라는 라이브러리를 사용하였는데, 다음 프로젝트에서는 직접 구현해보는 것도 좋겠다고 생각하였다.

관리자 페이지는 유저 관리와 편지 관리 페이지로 나누어져 있는데 유저의 경우 이메일과 이름으로 검색, 편지관리는 받는사람이나 보낸 사람의 이메일로 검색이 가능하도록 구현하였다. 두 관리 페이지 모두 데이터의 삭제가 가능하고 삭제 전에 쿠키에 담긴 accessToken을 해독하여 관리자 계정인지 여부를 확인하는 과정을 거쳤고, Confirm과 Modal창은 직접 만들어서 사용하였다.

로딩 페이지도 svg를 사용하여 만들어놓았기 때문에 화면이 처음 보여질때 로딩페이지가 보여지고 응답받은 후 데이터가 보여지도록 state와 삼항 연산자를 활용하여 구현하였다. 페이지네이션을 통해 데이터가 바뀔때는 중간에 로딩페이지가 보여질 필요가 없기 때문에 별도의 함수를 만들어서 처리하였다.

두 페이지의 로직이 동일하기 때문에 유저 관리 페이지를 구현한 이후 편지 관리는 변수명이나 select option의 value 정도만 다르게 해서 구현하였다.

//유저 관리 페이지
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import Pagination from 'react-js-pagination';
import Loding from '../Loding/Loding';

export default function AdminUser() {
  const [page, setPage] = useState(1);
  const [isLoding, setIsLoding] = useState(false);
  const [confirm, setConfirm] = useState(false);
  const [modal, setModal] = useState(false);
  const [data, setData] = useState([]);
  const [count, setCount] = useState(0);
  const [selected, setSelected] = useState('이름');
  const [name, setName] = useState(null);
  const [email, setEmail] = useState(null);
  const [searchWord, setSearchWord] = useState('');
  const [deleteId, setDeleteId] = useState(null);

  const selectList = ['이름', '이메일'];

  const handleSelect = (e) => {
    setSelected(e.target.value);
    setSearchWord('');
    setName(null);
    setEmail(null);
  };
  const handleSearchWord = (e) => {
    if (selected === '이름') {
      setSearchWord(e.target.value);
      setName(e.target.value);
      setEmail(null);
    } else {
      setSearchWord(e.target.value);
      setName(null);
      setEmail(e.target.value);
    }
  };

  const getUserData = async () => {
    await setIsLoding(true);
    await axios.get(
      `${process.env.REACT_APP_SERVER_API}/admin/user-list`,
      { params: { page, name, email } }
    )
    .then((res)=>{
      setData(res.data.data);
      setCount(res.data.count);
    })
    await setIsLoding(false);
  };

  const getUserDataPage = async () => {
    axios.get(
      `${process.env.REACT_APP_SERVER_API}/admin/user-list`,
      { params: { page, name, email } }
    )
    .then((res)=>{
      setData(res.data.data);
      setCount(res.data.count);
    })
  };

  const getFilterdData = async () => {
    await setIsLoding(true);
    await setPage(1);
    const res = await axios.get(
      `${process.env.REACT_APP_SERVER_API}/admin/user-list`,
      { params: { page, name, email } }
    );
    await setData(res.data.data);
    await setCount(res.data.count);
    await setIsLoding(false);
  };

  const deleteUserData = async () => {
    axios.delete(`${process.env.REACT_APP_SERVER_API}/admin/user`, {
      data: { id: deleteId },
      withCredentials: true,
    });
    
  };

  useEffect(() => {
    getUserDataPage();
  }, [page]);

  useEffect(() => {
    getUserData();
  }, []);


  return (
    <div className='adminUser-container'>
      <div className='box-search'>
        <div class='search-box'>
          <input
            type='text'
            id='search'
            placeholder='검색어를 입력하세요'
            value={searchWord}
            onChange={handleSearchWord}
          ></input>
          <span>
            <button id='searchButton'>
              <FontAwesomeIcon icon={faSearch} onClick={getFilterdData} />
            </button>
          </span>
        </div>
        <select onChange={handleSelect} value={selected}>
          {selectList.map((item) => (
            <option value={item} key={item}>
              {item}
            </option>
          ))}
        </select>
      </div>
      <div className='box-table'>
        <table>
          <th className='id'>
            <span>id</span>
          </th>
          <th>이름</th>
          <th>이메일</th>
          <th>보낸 편지 수</th>
          <th>받은 편지 수</th>
          <th>가입일</th>
          <th className='withdraw'>
            <span>탈퇴</span>
          </th>
          {isLoding ? (
            <tr className='box-loding'>
              <td colSpan='7'>
                <Loding />
              </td>
            </tr>
          ) : (
            data.length!==0 ? (
              data.map((el, id) => {
              return (
                <UserList
                  setDeleteId={setDeleteId}
                  setConfirm={setConfirm}
                  el={el}
                  key={id}
                />
              );
            })
            ) : (
              <tr className='box-none'>
              <td colSpan='7'>
                일치하는 데이터가 없습니다.
              </td>
            </tr>
            )
          )}
        </table>
      </div>
      <div>
        <Pagination
          activePage={page}
          itemsCountPerPage={10}
          totalItemsCount={count}
          pageRangeDisplayed={5}
          prevPageText={'‹'}
          nextPageText={'›'}
          onChange={setPage}
        />
      </div>
      {confirm ? (
        <ConfirmUser
          setDeleteId={setDeleteId}
          setConfirm={setConfirm}
          setModal={setModal}
          deleteUserData={deleteUserData}
        />
      ) : (
        ''
      )}
      {modal ? <ModalUser setDeleteId={setDeleteId} setModal={setModal} getUserData={getUserData}/> : ''}
    </div>
  );
}

function UserList({ el, setConfirm, setDeleteId }) {
  return (
    <tr>
      <td className='el-id'>{el.id}</td>
      <td>{el.name}</td>
      <td>{el.email}</td>
      <td>{el.writeNum}</td>
      <td>{el.receiveNum}</td>
      <td>{el.created_at.slice(0, 10)}</td>
      <td className='withdraw'>
        <FontAwesomeIcon
          icon={faTrashAlt}
          onClick={() => {
            setConfirm(true);
            setDeleteId(el.id);
          }}
        />
      </td>
    </tr>
  );
}

function ConfirmUser({ setConfirm, setModal, setDeleteId, deleteUserData }) {
  return (
    <div className='confirmUser-container'>
      <div className='box-confirm'>
        <img src='img/delete.svg' />
        <div className='confirm-message'>
          해당 유저 정보를 삭제하시겠습니까?
        </div>
        <div className='box-confirm-btn'>
          <span
            id='btn-cancel'
            onClick={() => {
              setConfirm(false);
              setDeleteId(null);
            }}
          >
            취소
          </span>
          <span
            id='btn-confirm'
            onClick={() => {
              setConfirm(false);
              setModal(true);
              deleteUserData();
            }}
          >
            확인
          </span>
        </div>
      </div>
    </div>
  );
}

function ModalUser({ setModal, setDeleteId, getUserData }) {
  return (
    <div className='modalUser-container'>
      <div className='box-modal'>
        <img src='img/success.svg' />
        <div className='modal-message'>삭제되었습니다.</div>
        <div>
          <span
            onClick={() => {
              setModal(false);
              setDeleteId(null);
              getUserData();
            }}
          >
            확인
          </span>
        </div>
      </div>
    </div>
  );
}
//유저 데이터 응답(검색어에 따른 필터링)
module.exports = async (req, res) => {
  try {
    const { name, email, page } = req.query;
    if (!name && !email) {
      const sql1 =
        'SELECT users.id, users.name, users.email, users.created_at, IFNULL(A.receiveNum,0) AS receiveNum, IFNULL(B.writeNum,0) AS writeNum from users LEFT JOIN (SELECT mails.receiverEmail, COUNT(mails.id) AS receiveNum FROM mails GROUP BY mails.receiverEmail) AS A ON A.receiverEmail=users.email LEFT JOIN (SELECT mails.writerEmail, COUNT(mails.id) AS writeNum FROM mails GROUP BY mails.writerEmail) AS B ON users.email=B.writerEmail ORDER BY users.id LIMIT ?,10;';
      const params = [(Number(page)-1)*10];
      const [rows1, fields1, err1] = await db.query(sql1, params);
      if (err1) {
        return res.status(404).send(err1);
      } else {
        const sql2 = 'SELECT COUNT(id) AS count FROM users';
        const [rows2, fields2, err2] = await db.query(sql2);
        if (err2) {
          return res.status(404).send(err2);
        } else {
          res.status(200).json({
            data: rows1,
            count: rows2[0]['count'],
          });
        }
      }
    } else if(name && !email) {
      const sql1 =
        'SELECT users.id, users.name, users.email, users.created_at, IFNULL(A.receiveNum,0) AS receiveNum, IFNULL(B.writeNum,0) AS writeNum from users LEFT JOIN (SELECT mails.receiverEmail, COUNT(mails.id) AS receiveNum FROM mails GROUP BY mails.receiverEmail) AS A ON A.receiverEmail=users.email LEFT JOIN (SELECT mails.writerEmail, COUNT(mails.id) AS writeNum FROM mails GROUP BY mails.writerEmail) AS B ON users.email=B.writerEmail WHERE users.name LIKE ? ORDER BY users.id LIMIT ?,10;';
      const params1 = [`%${name}%`,(Number(page)-1)*10];
      const [rows1, fields1, err1] = await db.query(sql1, params1);
      if (err1) {
        return res.status(404).send(err1);
      } else {
        const sql2 = 'SELECT COUNT(id) AS count FROM users WHERE name LIKE ?;';
        const params2 = [`%${name}%`]
        const [rows2, fields2, err2] = await db.query(sql2, params2);
        if (err2) {
          return res.status(404).send(err2);
        } else {
          res.status(200).json({
            data: rows1,
            count: rows2[0]['count'],
          });
        }
      }
    } else if(!name && email){
      const sql1 =
      'SELECT users.id, users.name, users.email, users.created_at, IFNULL(A.receiveNum,0) AS receiveNum, IFNULL(B.writeNum,0) AS writeNum from users LEFT JOIN (SELECT mails.receiverEmail, COUNT(mails.id) AS receiveNum FROM mails GROUP BY mails.receiverEmail) AS A ON A.receiverEmail=users.email LEFT JOIN (SELECT mails.writerEmail, COUNT(mails.id) AS writeNum FROM mails GROUP BY mails.writerEmail) AS B ON users.email=B.writerEmail WHERE users.email LIKE ? ORDER BY users.id LIMIT ?,10;';
    const params1 = [`%${email}%`,(Number(page)-1)*10];
    const [rows1, fields1, err1] = await db.query(sql1, params1);
    if (err1) {
      return res.status(404).send(err1);
    } else {
      const sql2 = 'SELECT COUNT(id) AS count FROM users WHERE email LIKE ?;';
      const params2 = [`%${email}%`]
      const [rows2, fields2, err2] = await db.query(sql2, params2);
      if (err2) {
        return res.status(404).send(err2);
      } else {
        res.status(200).json({
          data: rows1,
          count: rows2[0]['count'],
        });
      }
    }
    }
  } catch (err) {
    throw err;
  }
};

데이터를 보내주는 응답의 경우 한 페이지에 해당하는 10개의 데이터만 보내지도록 하고 페이지네이션을 위해 총 데이터의 개수를 함께 data에 담아 전송해주었다.

//유저 정보 삭제 응답
module.exports = async (req, res) => {
  try {
    const accessToken = req.cookies.accessToken;
    if (!accessToken) {
      return res.status(403).send();
    }

    const verified = jwt.verify(
      accessToken,
      process.env.ACCESS_SECRET,
      (err, decoded) => {
        if (err) return null;
        return decoded;
      }
    );

    if (!verified) {
      return res.status(401).send();
    }

    const sql1 = 'SELECT * FROM users WHERE id=?';
    const params1 = [verified.id];

    const [rows1, fields1, err1] = await db.query(sql1, params1);
    if(err1) {
      return res.status(401).send();
    }
    if (rows1[0]['admin']) {
      const { id } = req.body;
      if (!id) {
        return res.status(404).send();
      }
      const sql2 = 'DELETE FROM users WHERE id=?';
      const params2 = [id];
      db.query(sql2, params2, (err, result) => {
        if (err) throw err;
        else {
          return res.status(200).json();
        }
      });
    } else {
      return res.status(403).send();
    }
  } catch (err) {
    throw err;
  }
};

삭제 요청의 경우 쿠키에 담긴 accessToken을 해독하고 데이터베이스에서 확인하여 관리자가 보낸 삭제 요청만 올바르게 처리할 수 있도록 하였다.

마치며

2주 프로젝트이다보니 새로운 기술을 사용하거나, 짜기 어려운 로직을 구현하지는 않아서 해결하기 어려웠던 부분은 많이 없는 것 같다. 다음 프로젝트는 새로운 라이브러리나 프레임워크를 한 두개 정도 사용해서 발전할 수 있는 정도의 난이도의 기능 구현을 할 수 있도록 방향을 잡아야겠다.

좋은 웹페이지 즐겨찾기