본문 바로가기

프로젝트 개발일지/플케어

[기능 구현] 프로젝트 관리 탭을 구현해보자.

아래 사진은 구현된 프로젝트 관리 탭 사진이다.

 

관리 탭의 경우, 리더에게만 보이게 해서 리더만 접근이 가능하도록 처리해 놨다.

일반 멤버에게는 관리 탭 자체가 보이지 않는다.

 

프로젝트 관리 탭에서의 기능은 딱 세 가지이다.

- 프로젝트 완료 처리

- 프로젝트 삭제

- 프로젝트 정보 수정

 

이제부터 코드와 함께 조금 더 자세히 살펴보도록 하자.

import { useState } from "react";
import { useLocation } from "react-router-dom";

import { getStringDate } from "../../utils/date";
import { getProjectId } from "../../utils/getProjectId";

import projectButtonImgUrl from "../../assets/project-management-img.png";
import completedProjectButtonImgUrl from "../../assets/completed-project-management-img.png";

import ProjectButtonModal from "../Management/ProjectButtonModal";
import ProjectEditor from "../Management/ProjectEditor";

import { useQuery } from "react-query";
import { getProjectData } from "../../lib/apis/projectManagementApi";
import { getCompleteProjectData } from "../../lib/apis/managementApi";

const ProjectManagement = () => {
  const projectId = getProjectId(useLocation());

  const { data } = useQuery(["managementProject", projectId], () =>
    getProjectData(projectId)
  );

  const { data: isCompleted } = useQuery(
    ["completeProjectData", projectId],
    () => getCompleteProjectData(projectId)
  );

  const [deleteModalVisible, setDeleteModalVisible] = useState(false);
  const [deleteProjectId, setDeleteProjectId] = useState();

  const [completeModalVisible, setCompleteModalVisible] = useState(false);
  const [completeProjectId, setCompleteProjectId] = useState();

  const [editModalVisible, setEditModalVisible] = useState(false);
  const [editData, setEditData] = useState({
    projectId: 0,
    title: "",
    startDate: new Date(),
    endDate: new Date(),
    imageUrl: "",
    description: "",
  });

  const handleCompleteProjectClick = (e, projectId) => {
    e.preventDefault();

    setCompleteProjectId(projectId);

    setCompleteModalVisible(true);
  };

  const handleDeleteProjectClick = (e, projectId) => {
    e.preventDefault();

    setDeleteProjectId(projectId);

    setDeleteModalVisible(true);
  };

  const handleEditProjectClick = (e) => {
    e.preventDefault();

    setEditModalVisible(true);

    setEditData({
      title: data.title,
      projectId: projectId,
      startDate: getStringDate(new Date(data.startDate)),
      endDate: getStringDate(new Date(data.endDate)),
      description: data.description,
      imageUrl: data.imageUrl,
    });
  };

  return (
    <div className="project-management">
      {isCompleted ? (
        <div className="completed-project">
          <div className="project-button-wrapper">
            <figure
              style={{
                backgroundImage: `url(${completedProjectButtonImgUrl})`,
              }}
              onClick={(e) => handleCompleteProjectClick(e, projectId)}
            />
            <h1>프로젝트 완료</h1>
          </div>
          <div className="project-button-wrapper">
            <figure
              style={{
                backgroundImage: `url(${completedProjectButtonImgUrl})`,
              }}
              onClick={(e) => handleDeleteProjectClick(e, projectId)}
            />
            <h1>프로젝트 삭제</h1>
          </div>
          <div className="project-button-wrapper">
            <figure
              style={{
                backgroundImage: `url(${completedProjectButtonImgUrl})`,
              }}
              onClick={(e) => handleEditProjectClick(e)}
            />
            <h1>프로젝트 수정</h1>
          </div>
          {completeModalVisible && (
            <ProjectButtonModal
              type={"완료"}
              projectId={completeProjectId}
              modalVisible={completeModalVisible}
              setModalVisible={setCompleteModalVisible}
            />
          )}
          {deleteModalVisible && (
            <ProjectButtonModal
              type={"삭제"}
              projectId={deleteProjectId}
              modalVisible={deleteModalVisible}
              setModalVisible={setDeleteModalVisible}
            />
          )}
          {editModalVisible && (
            <ProjectEditor
              isModalVisible={editModalVisible}
              setIsModalVisible={setEditModalVisible}
              isEdit={true}
              editData={editData}
            />
          )}
        </div>
      ) : (
        <div className="ongoing-project">
          <div className="project-button-wrapper">
            <figure
              style={{
                backgroundImage: `url(${projectButtonImgUrl})`,
              }}
              onClick={(e) => handleCompleteProjectClick(e, projectId)}
            />
            <h1>프로젝트 완료</h1>
          </div>
          <div className="project-button-wrapper">
            <figure
              style={{
                backgroundImage: `url(${projectButtonImgUrl})`,
              }}
              onClick={(e) => handleDeleteProjectClick(e, projectId)}
            />
            <h1>프로젝트 삭제</h1>
          </div>
          <div className="project-button-wrapper">
            <figure
              style={{
                backgroundImage: `url(${projectButtonImgUrl})`,
              }}
              onClick={(e) => handleEditProjectClick(e)}
            />
            <h1>프로젝트 수정</h1>
          </div>
          {completeModalVisible && (
            <ProjectButtonModal
              type={"완료"}
              projectId={completeProjectId}
              modalVisible={completeModalVisible}
              setModalVisible={setCompleteModalVisible}
            />
          )}
          {deleteModalVisible && (
            <ProjectButtonModal
              type={"삭제"}
              projectId={deleteProjectId}
              modalVisible={deleteModalVisible}
              setModalVisible={setDeleteModalVisible}
            />
          )}
          {editModalVisible && (
            <ProjectEditor
              isModalVisible={editModalVisible}
              setIsModalVisible={setEditModalVisible}
              isEdit={true}
              editData={editData}
            />
          )}
        </div>
      )}
    </div>
  );
};

export default ProjectManagement;

 

일단, 해당 기능들을 설명하기에 앞서 기본적으로 먼저 불러오는 데이터들에 대해서 알아보자.

1. 프로젝트 완료 여부

2. 프로젝트 정보

 

먼저, 프로젝트 완료 여부를 불러오는 이유는 프로젝트 완료 후에는 관리 탭에서 하는 기능 모두를 막기 위해서이다.

프로젝트 완료 후에는 다음과 같이 관리 탭 상 모든 기능이 비활성화된다.

그래서 만약 프로젝트 완료 후라면 리더에게 다른 UI로, 또 다르게 작동하도록 해야 하기 때문에 관리 탭 초기 렌더링 시 해당 데이터를 불러오도록 했다.

 

두 번째로 불러오는 데이터는 프로젝트 정보이다.

프로젝트 수정 시에 다음과 같이 프로젝트 정보가 담긴 모달창이 뜬다.

이때, 사용자가 이전에 생성해 두었던 프로젝트 정보를 함께 띄워줘야 하기 때문에 이 정보 또한 관리 탭 초기 렌더링 시에 불러오게 된다.

 

이제 세 기능들에 대해서 알아보자.

 

먼저, 프로젝트 완료 기능이다.

프로젝트 완료 버튼 클릭 시 경고성 모달창을 띄워주고, 모달창에서도 완료하기 버튼을 누르게 되면, 프로젝트 완료 처리가 된다.

이후, 프로젝트 목록 페이지로 리다이렉트시킨다.

 

이 로직을 코드로 좀 더 살펴보자.

const handleCompleteClick = () => {
    completeMutate(projectId);

    setModalVisible(false);
};
const { mutate: completeMutate } = useMutation(completeProject, {
    onSuccess: () => {
      queryClient.invalidateQueries(["managementOngoingProjectList"]);
      queryClient.invalidateQueries(["managementAllProjectList"]);
      queryClient.invalidateQueries(["completeProjectData"]);

      routeTo("/management");
      toast.success("완료 처리되었습니다!");
    },
    onError: () => {
      toast.error("완료 처리 실패하였습니다. 잠시 후 다시 시도해주세요.");
    },
});
export const completeProject = async (projectId) => {
  const response = await customAxios.post(`/auth/project/${projectId}/complete`);

  return response.data;
};

다른 부분들은 특이 사항이 없고, 이 프로젝트를 완료 처리한 후 쿼리를 무효화시켜주어야 한다.

지금 보면, 이 프로젝트를 완료 처리했을 때, 변경되어야 하는 데이터는 세 가지이다.

1. 진행 중인 프로젝트 목록

2. 전체 프로젝트 목록

3. 프로젝트 완료 여부

그래서 이를 위해서 queryClient.invalidateQueries 코드를 각각 작성해 주었다.

 

그 다음으로, 프로젝트 삭제하는 경우를 한 번 살펴보자.

프로젝트 삭제의 경우에도, 삭제 버튼을 클릭하면 경고성 모달창이 하나 뜬다.

그래도 삭제 버튼을 누르면 삭제 로직이 작동하게 된다.

삭제가 완료되면 프로젝트 목록 페이지로 이동하게 되는데, 삭제된 프로젝트의 정보에는 접근하면 안 되기 때문에 뒤로 가기를 막았다.

 

이제 코드로 조금 더 자세히 살펴보도록 하자.

const handleDeleteClick = () => {
    deleteMutate(projectId);

    setModalVisible(false);
};
const { mutate: deleteMutate } = useMutation(deleteProject, {
    onSuccess: () => {
      queryClient.invalidateQueries(["managementOngoingProjectList"]);
      queryClient.invalidateQueries(["managementAllProjectList"]);

      routeTo("/management");
      toast.success("삭제되었습니다!");
    },
    onError: () => {
      toast.error("삭제 실패하였습니다. 잠시 후 다시 시도해주세요.");
    },
});
export const deleteProject = async (projectId) => {
  const response = await customAxios.delete(`/auth/project/${projectId}`);

  return response.data.projectId;
};

위에서 프로젝트 완료 처리하기 로직이랑 코드 흐름이 거의 비슷하다.

 

추가적으로 프로젝트 삭제 후에는 전체 프로젝트 목록과 진행 중인 프로젝트 목록이 달라져야 하기 때문에 이에 대한 쿼리 무효화 또한 실행되도록 설정하였다.

 

이제 관리 탭의 마지막 기능인 프로젝트 수정하기 로직에 대해서 살펴보자.

프로젝트 수정하기 버튼을 클릭하면 기존에 사용자가 작성했던 프로젝트 정보가 담긴 모달창이 뜨게 된다.

 

아래 사진이 그 예시이다.

여기서 프로젝트 정보를 수정한 후 작성 완료 버튼을 클릭하면 프로젝트 정보가 수정되고, 프로젝트 목록 페이지로 이동시킨다.

 

이제 코드를 통해 조금 더 자세히 살펴보자.

const handleEditProjectClick = (e) => {
    e.preventDefault();

    setEditModalVisible(true);

    setEditData({
      title: data.title,
      projectId: projectId,
      startDate: getStringDate(new Date(data.startDate)),
      endDate: getStringDate(new Date(data.endDate)),
      description: data.description,
      imageUrl: data.imageUrl,
    });
};
{editModalVisible && (
  <ProjectEditor
    isModalVisible={editModalVisible}
    setIsModalVisible={setEditModalVisible}
    isEdit={true}
    editData={editData}
  />
)}

 

처음에 불러온 프로젝트 데이터를 설정해줘서 이 정보들을 ProjectEditor 컴포넌트에 props로 넘겨준다.

ProjectEditor 컴포넌트는 프로젝트 생성 시 사용했던 모달창을 재사용한 컴포넌트이다.

import { useEffect, useRef, useState } from "react";

import { getStringDate } from "../../utils/date";

import projectDefaultImg from "../../assets/project-default-img.jpg";

import { deleteImage } from "../../lib/apis/managementApi";
import ModalContainer from "../common/ModalContainer";
import useManagementMutation from "../../hooks/useManagementMutation";

import { toast } from "react-toastify";
import Button from "../common/Button";
import { handleImageUploader } from "../../utils/handleImageUploader";

const ProjectEditor = ({
  isModalVisible,
  setIsModalVisible,
  isEdit = false,
  editData = null,
}) => {
  const { createMutate, editMutate } = useManagementMutation();

  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [startDate, setStartDate] = useState(getStringDate(new Date()));
  const [endDate, setEndDate] = useState(getStringDate(new Date()));
  const [imgUrl, setImgUrl] = useState("");
  const [responseImgUrl, setResponseImgUrl] = useState("");

  const descriptionRef = useRef(null);

  const inputRef = useRef(null);

  const handleModalClose = () => {
    setIsModalVisible(false);
  };

  const handleChangeTitle = (e) => {
    setTitle(e.target.value);
  };

  const handleChangeStartDate = (e) => {
    setStartDate(e.target.value);
  };

  const handleChangeEndDate = (e) => {
    setEndDate(e.target.value);
  };

  const handleChangeContent = (e) => {
    setDescription(e.target.value);
  };

  const handleChangeImage = async (e) => {
    const imgUrl = await handleImageUploader(e.target.files);

    setImgUrl(imgUrl);
    setResponseImgUrl(imgUrl);
  };

  const handleSubmitNewProject = async () => {
    if (title.length < 2) {
      toast.error("프로젝트 이름은 두 글자 이상 작성해주세요.");
      return;
    }

    if (description.length < 5) {
      toast.error("프로젝트 설명은 다섯 글자 이상 작성해주세요.");
      descriptionRef.current.focus();
      return;
    }

    if (new Date(startDate).getTime() > new Date(endDate).getTime()) {
      toast.error("진행 기간이 잘못 설정되었습니다. 다시 설정해주세요.");
      return;
    }

    if (isEdit) {
      editMutate({
        projectId: editData.projectId,
        title,
        description,
        state: "ONGOING",
        startDate: getStringDate(new Date(startDate)),
        endDate: getStringDate(new Date(endDate)),
        imageUrl: responseImgUrl,
      });
    } else {
      createMutate({
        title: title,
        description: description,
        startDate: getStringDate(new Date(startDate)),
        endDate: getStringDate(new Date(endDate)),
        imageUrl: responseImgUrl,
      });
    }

    setIsModalVisible(false);
  };

  const handleUploadImageClick = () => {
    if (!inputRef.current) {
      toast.error("잘못된 접근입니다. 다시 이미지를 업로드해주세요.");
      return;
    }

    inputRef.current.click();
  };

  const handleRemoveImageClick = () => {
    deleteImage("");

    setImgUrl(null);
    setResponseImgUrl(null);
  };

  useEffect(() => {
    if (isEdit) {
      setTitle(editData.title);
      setStartDate(editData.startDate);
      setEndDate(editData.endDate);
      setImgUrl(editData.imageUrl);
      setDescription(editData.description);
    }
  }, [editData, isEdit]);

  return (
    <ModalContainer
      open={isModalVisible}
      onClose={handleModalClose}
      width={"60%"}
      height={"75%"}
    >
      <div className="project-editor">
        <div className="project-editor-heading">
          <input
            className="project-editor-heading-input"
            type="text"
            required
            value={title}
            onChange={handleChangeTitle}
            placeholder="프로젝트 이름을 입력하세요"
          />
        </div>
        <div className="project-editor-body">
          <div className="project-editor-left-col">
            <div className="project-editor-img-wrapper">
              <figure
                style={{
                  backgroundImage: `url(${
                    imgUrl ? imgUrl : projectDefaultImg
                  })`,
                }}
              />
              <input
                className="image-input"
                type="file"
                accept="image/*"
                ref={inputRef}
                onChange={handleChangeImage}
              />
              <Button
                onClick={handleUploadImageClick}
                text={"이미지 업로드"}
                size={"small"}
              />
              <Button
                onClick={handleRemoveImageClick}
                text={"이미지 제거"}
                size={"small"}
              />
            </div>
          </div>
          <div className="project-editor-right-col">
            <div className="project-editor-period-first-row">
              <h1>진행 기간:</h1>
              <div>
                <input
                  className="project-editor-period-start-date"
                  type="date"
                  required
                  value={startDate}
                  onChange={handleChangeStartDate}
                  data-placeholder="시작 일자"
                />
                -
                <input
                  className="project-editor-period-end-date"
                  type="date"
                  required
                  value={endDate}
                  onChange={handleChangeEndDate}
                  data-placeholder="종료 일자"
                />
              </div>
            </div>
            <div className="project-editor-period-second-row">
              <textarea
                value={description}
                onChange={handleChangeContent}
                ref={descriptionRef}
                placeholder="프로젝트 설명을 작성하세요"
                required
              />
            </div>
          </div>
        </div>
        <div className="button-wrapper">
          <Button
            text="작성 완료"
            size={"small"}
            type={"positive"}
            onClick={handleSubmitNewProject}
          />
        </div>
      </div>
    </ModalContainer>
  );
};

export default ProjectEditor;

여기서 추가적인 수정 로직이 이루어진다.

수정의 경우 isEdit이라는 필드로 프로젝트 생성과 구분했다.

결국, 수정하는 경우 작성 완료 버튼을 누르게 되면 editMutate 함수가 실행된다.

 

editMutate({
  projectId: editData.projectId,
  title,
  description,
  state: "ONGOING",
  startDate: getStringDate(new Date(startDate)),
  endDate: getStringDate(new Date(endDate)),
  imageUrl: responseImgUrl,
});
const { mutate: editMutate } = useMutation(editProject, {
    onSuccess: () => {
      queryClient.invalidateQueries(["managementOngoingProjectList"]);
      queryClient.invalidateQueries(["managementAllProjectList"]);

      routeTo("/management");
      toast.success("수정되었습니다!");
    },
    onError: () => {
      toast.error("수정 실패하였습니다. 잠시 후 다시 시도해주세요.");
    },
});
export const editProject = async (newProjectData) => {
  const response = await customAxios.put(
    `/auth/project/${newProjectData.projectId}`,
    {
      title: newProjectData.title,
      description: newProjectData.description,
      state: newProjectData.state,
      startDate: newProjectData.startDate,
      endDate: newProjectData.endDate,
      imageUrl: newProjectData.imageUrl,
    }
  );

  return response.data;
};

 

프로젝트를 수정하는 경우도 전체 프로젝트 리스트와 진행 중인 프로젝트 리스트에서도 정보가 변경되어야 하기 때문에 이에 대한 쿼리 무효화도 진행했다.