본문 바로가기

TIL

TIL - 리액트로 가계부 만들기 - 3

목표

  1. alert창, confirm창 모달창으로 변경
  2. 메인 페이지 그래프 컴포넌트 추가 
  3. 가계부 항목 정렬순으로 표시

주요 학습 내용

  1. 모달창 상태 Redux 활용해서 저장소에서 관리
  2. styled-components 활용해서 총 지출 그래프 구현
  3. useState와 switch 문 활용해서 항목 정렬

1. 모달창 상태 Redux 활용해서 저장소에서 관리

src
└── assets
    └── components
        └── Modal
            ├── AlertModal.jsx
            ├── ConfirmModal.jsx
            └── Modal.styled.js

 

Alert 창과 Confirm 창을 모달 창으로 구현하고, styled-components를 사용하여 스타일링했습니다.

 

// src/redux/slices/modalSlice.js 파일 생성
import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  isConfirmModalOpen: false,
  isAlertModalOpen: false,
  alertMessage: "",
};

const modalSlice = createSlice({
  name: "modal",
  initialState,
  reducers: {
    openConfirmModal: (state) => {
      state.isConfirmModalOpen = true;
    },
    closeConfirmModal: (state) => {
      state.isConfirmModalOpen = false;
    },
    openAlertModal: (state, action) => {
      state.isAlertModalOpen = true;
      state.alertMessage = action.payload;
    },
    closeAlertModal: (state) => {
      state.isAlertModalOpen = false;
      state.alertMessage = "";
    },
  },
});

export const {
  openConfirmModal,
  closeConfirmModal,
  openAlertModal,
  closeAlertModal,
} = modalSlice.actions;

export default modalSlice.reducer;
// store에 모달 reducer 추가
import { configureStore } from "@reduxjs/toolkit";
import fetchedDataReducer from "../slices/fetchedDataSlice";
import modalReducer from "../slices/modalSlice";

const store = configureStore({
  reducer: {
    fetchedData: fetchedDataReducer,
    modal: modalReducer,
  },
});

export default store;

 

기존에는 모달 기능이 필요한 컴포넌트에서 useState를 사용해서 모달창을 구현했지만, 모달이 필요한 컴포넌트가 하나의 페이지가 아니여서 Redux를 활용하여 중앙에서 전역 상태를 관리하도록 변경했습니다.

 

 

 

2. styled-components 활용해서 총 지출 그래프 구현

// 홈페이지 컴포넌트에 그래프를 표시 할 ExpenseSummaryByMonth 컴포넌트 추가
function Homepage() {
  const [filterMonth, setFilterMonth] = useState("");
  return (
    <StrMain className="main-container">
      <StrSection>
        <ExpenseForm />
      </StrSection>
      <StrSection>
        <MonthlyExpenses setFilterMonth={setFilterMonth} />
      </StrSection>
      <StrSection>
        <ExpenseSummaryByMonth filterMonth={filterMonth} />
      </StrSection>
      <StrSection>
        <ExpenseListByMonth filterMonth={filterMonth} />
      </StrSection>
    </StrMain>
  );
}
// ExpenseSummaryByMonth.jsx
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import {
  CategoryDiv,
  CategoryWrapDiv,
  GraphBar,
  TotalAmount,
  TotalAmountGraph,
} from "./ExpenseSummaryByMonth.styled";

function ExpenseSummaryByMonth({ filterMonth }) {
  const [totalMonthlyExpense, setTotalMonthlyExpense] = useState(0);
  const [categoryExpenses, setCategoryExpenses] = useState({});
  const fetchedData = useSelector((state) => state.fetchedData);

  useEffect(() => {
    const filtered = fetchedData.filter((item) => {
      const month = new Date(item.date).getMonth();
      return month === filterMonth;
    });

    const initialCategoryExpenses = {
      여행: 0,
      미용: 0,
      도서: 0,
      식비: 0,
      기타: 0,
    };

    const updatedCategoryExpenses = filtered.reduce(
      (acc, currentItem) => {
        const category = currentItem.item;
        const amount = currentItem.amount;

        const categoryName = ["여행", "미용", "도서", "식비"].includes(category)
          ? category
          : "기타";

        acc[categoryName] += amount;

        return acc;
      },
      { ...initialCategoryExpenses }
    );

    setTotalMonthlyExpense(
      formattedAmount(
        Object.values(updatedCategoryExpenses).reduce(
          (acc, current) => acc + current,
          0
        )
      )
    );
    setCategoryExpenses(updatedCategoryExpenses);
  }, [fetchedData, filterMonth]);

  const formattedAmount = (amount) =>
    new Intl.NumberFormat("ko-KR").format(amount);

  const calculateCategoryPercentage = (categoryExpense) => {
    const totalExpense = Object.values(categoryExpenses).reduce(
      (acc, current) => acc + current,
      0
    );

    if (totalExpense === 0) return "0.00%";

    const percentage = (categoryExpense / totalExpense) * 100;
    return percentage.toFixed(2) + "%";
  };

  return (
    <>
      <TotalAmount>
        {filterMonth + 1}월 총 지출: {totalMonthlyExpense} 원
      </TotalAmount>
      <TotalAmountGraph>
        {Object.entries(categoryExpenses).map(([category, expense]) => (
          <GraphBar
            key={category}
            $category={category}
            style={{ width: calculateCategoryPercentage(expense) }}
          />
        ))}
      </TotalAmountGraph>
      <CategoryWrapDiv>
        {Object.entries(categoryExpenses).map(([category, expense]) => {
          if (expense > 0) {
            return (
              <CategoryDiv key={category} $category={category}>
                <div></div>
                {category}: {formattedAmount(expense)}원 (
                {calculateCategoryPercentage(expense)})
              </CategoryDiv>
            );
          }
          return null;
        })}
      </CategoryWrapDiv>
    </>
  );
}

export default ExpenseSummaryByMonth;
import styled from "styled-components";

const TotalAmount = styled.div`
  text-align: center;
  font-weight: bold;
  font-size: 26px;
  margin-bottom: 20px;
`;
const TotalAmountGraph = styled.div`
  display: flex;
  align-items: center;
  margin-bottom: 20px;
  height: 40px;
  background-color: rgb(233, 236, 239);
  border-radius: 8px;
  overflow: hidden;
`;

const GraphBar = styled.div`
  height: 100%;
  background-color: ${({ $category }) => getCategoryColor($category)};
`;

const getCategoryColor = ($category) => {
  switch ($category) {
    case "여행":
      return "rgb(0, 123, 255)";
    case "미용":
      return "rgb(40, 167, 69)";
    case "도서":
      return "rgb(220, 53, 69)";
    case "식비":
      return "rgb(255, 193, 7)";
    case "기타":
      return "rgb(23, 162, 184)";
    default:
      return;
  }
};

const CategoryWrapDiv = styled.div`
  display: flex;
  justify-content: center;
  gap: 20px;
  flex-wrap: wrap;
  margin-bottom: 10px;
`;

const CategoryDiv = styled.div`
  display: flex;
  align-items: center;
  font-size: 14px;
  color: rgb(85, 85, 85);

  div {
    width: 20px;
    height: 10px;
    background-color: ${({ $category }) => getCategoryColor($category)};
    margin-right: 8px;
  }
`;

export {
  CategoryDiv,
  CategoryWrapDiv,
  GraphBar,
  TotalAmount,
  TotalAmountGraph,
};

 

ExpenseSummaryByMonth 컴포넌트는 filterMonth를 기준으로 useSelector를 사용해 fetchedData를 받아와 가공한 데이터를 화면에 표시합니다. 의존성 배열에 fetchedData와 filterMonth를 포함시켜, 데이터 변경 시 리렌더링 되도록 했습니다. styled-components를 활용해 카테고리별로 스타일을 조건부로 할당했습니다.

 

 

 

3. 가계부 항목 정렬해서  표시하기

import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router-dom";

import { loadFetchedData } from "@redux/slices/fetchedDataSlice";
import {
  ArrowIcon,
  NoExpenseDiv,
  Select,
  SelectWrapDiv,
  StrDateItemWrapDiv,
  StrDiv,
  StrItemWrapDiv,
} from "./ExpenseListByMonth.styled";

function ExpenseListByMonth({ filterMonth }) {
  const dispatch = useDispatch();
  const fetchedData = useSelector((state) => state.fetchedData);
  const [filteredData, setFilteredData] = useState([]);
  const [sortOption, setSortOption] = useState("amount");

  useEffect(() => {
    dispatch(loadFetchedData());
  }, [dispatch]);

  useEffect(() => {
    const filtered = fetchedData.filter((item) => {
      const month = new Date(item.date).getMonth();
      return month === filterMonth;
    });

    switch (sortOption) {
      case "amount":
        filtered.sort((a, b) => b.amount - a.amount); // 가격 순
        break;
      case "category":
        filtered.sort((a, b) => a.item.localeCompare(b.item)); // 항목 순
        break;
      case "date":
        filtered.sort((a, b) => new Date(a.date) - new Date(b.date)); // 최신 순
        break;
      default:
        break;
    }

    setFilteredData(filtered);
  }, [fetchedData, filterMonth, sortOption]);

  const formattedAmount = (amount) =>
    new Intl.NumberFormat("ko-KR").format(amount);

  return (
    <StrDiv>
      <SelectWrapDiv>
        <Select
          value={sortOption}
          onChange={(e) => setSortOption(e.target.value)}
        >
          <option value="amount">가격 순</option>
          <option value="category">항목 순</option>
          <option value="date">최신 순</option>
        </Select>
        <ArrowIcon>&#9660;</ArrowIcon>
      </SelectWrapDiv>
      {filteredData.length === 0 ? (
        <NoExpenseDiv>지출이 없습니다.</NoExpenseDiv>
      ) : (
        filteredData.map((expense) => (
          <Link key={expense.id} to={`/expenses/${expense.id}`}>
            <StrItemWrapDiv>
              <StrDateItemWrapDiv>
                <span>{expense.date}</span>
                <span>
                  {expense.item} - {expense.description}
                </span>
              </StrDateItemWrapDiv>
              <span>{formattedAmount(expense.amount)}원</span>
            </StrItemWrapDiv>
          </Link>
        ))
      )}
    </StrDiv>
  );
}

export default ExpenseListByMonth;

 

switch 문과 select 태그로 드롭다운 메뉴를 만들어 정렬 기능을 구현했습니다. useState 훅을 연결하여 상태가 변할 때마다 정렬이 바로 적용되도록 했습니다.

 

 


결론

  • 전역 상태 관리를 통해 props drilling 문제를 해결하고, 모달 창의 확장성과 유지 보수성을 높였습니다.
  • switch 문과 useState를 사용하여 다양한 정렬 옵션을 제공했습니다.
  • styled-components를 활용하여 카테고리별 그래프의 색상을 조건부로 설정했습니다.

오늘은 모달 창을 구현하고, 모달 창의 상태를 Redux를 활용하여 중앙 저장소에서 관리했으며, styled-components를 활용하여 카테고리별로 조건부 스타일링을 적용했습니다. 또한 useState와 select 태그를 활용하여 정렬 기능을 구현했고 이번 학습을 통해 상태 관리의 중요성과 styled-components를 활용한 조건부 스타일링의 유용성을 체감할 수 있었습니다.