로그인 기능 구현 시, 백엔드에서 전달받은 토큰을 Recoil 상태로 관리하는 방식을 채택했습니다. 하지만 페이지 새로고침 시 Recoil 상태가 초기화되는 문제가 발생했습니다.

이 문제는 많은 개발자들이 겪는 공통적인 문제이며, 이를 해결하기 위한 방법으로 Recoil-persist 라이브러리를 사용할 수 있습니다. Recoil-persist를 사용하면 새로고침을 하더라도 Recoil 상태가 sessionStorage 또는 localStorage에 저장되어 유지됩니다. 기본 설정으로는 key가 'recoil-persist'로 지정되며, 데이터는 localStorage에 저장됩니다.

 

예제 1)

import { atom } from 'recoil';
import { recoilPersist } from 'recoil-persist';

//1. 아무것도 설정 안 하고 쓰는 경우
//localStorage에 저장되며, key 이름은 'recoil-persist'로 저장됨
const { persistAtom } = recoilPersist();

//2. sessionStorage에 저장하고 싶은 경우
//Next.js를 쓴다면 sessionStorage는 아래와 같이 따로 설정 필요
const sessionStorage = 
      typeof window !== 'undefined' ? window.sessionStorage : undefined

const { persistAtom } = recoilPersist({
  key: '내맘대로 정하는 키 이름',
  storage: sessionStorage,
});

//Recoil-persist를 적용시키려면 아래의 effects_UNSTABLE을 적어주어야 한다.
const myAtom = atom<MyAtomType>({
  key: 'myAtomKey',
  default: myDefaultState,
  effects_UNSTABLE: [persistAtom],
});

 

예제 2)

import { atom } from 'recoil';
import { recoilPersist } from 'recoil-persist';
import { LoggedInUser } from '../types/user';

// sessionStorage에 상태를 저장하기 위한 Recoil Persist 설정
const { persistAtom: loggedInUserPersist } = recoilPersist({
  key: 'loggedInUserPersist', // Key to identify the storage
  storage: sessionStorage, // Use sessionStorage for persistence
});

export const loggedInUserState = atom<LoggedInUser | null>({
  key: 'loggedInUserState', // unique ID (with respect to other atoms/selectors)
  default: null, // initial value (aka initial state)
  effects_UNSTABLE: [loggedInUserPersist], // Apply persistence
});

상태관리

  • React는 단방향으로 바인딩을하는 라이브러리
  • 리액트에서 props는 부모▶자식 방향으로만 단방향으로 전달이 가능함.
  • props드릴링을 통해 state를 부모▶자식 전달가능

 

자식컴포넌트가 부모 컴포넌트의 state를 바꾸는 방법?

  1. 부모state를 변경할 수 있는 setState함수를 자식 컴포넌트에 props로 전달하며 드릴링. (depth가 깊어질 경우 비효율적) ,자식 컴포넌트에서 전달받은 setState함수는 부모컴포넌트의 state를 변경시킬 수 있다.
  2. prop드릴링을 방지를 위한, State management tool( redux, recoil ..)사용

atom은 결국 컴포넌트의 state를 manageable하게 원격으로 공유하기 위해 사용하는 것.

 

Recoil 시작하기

설치

npm i recoil

 또는

yarn add recoil

 

store를 별도로 생성해줘야 하는 Redux와 달리 리코일은 RecoilRoot만 제공해도 자동으로 store가 생성됩니다.

 

import { RecoilRoot } from 'recoil';
import { ToastContainer } from 'react-toastify';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <RecoilRoot>
    <Router>
      <ToastContainer />
      <App />
    </Router>
  </RecoilRoot>,
);
  • 리코일을 적용하기 위해서는 <RecoilRoot>컴포넌트로 래핑해주는 과정이 필요하다.

atom이란?

  • atom 은 기존의 redux에서 쓰이는 store(state저장소) 와 유사한 개념으로, 상태의 단위
  • atom은 unique한 id인 key로 구분됨.
  • atom은 어떤 컴포넌트에서나 읽고 쓸 수 있다(useRecoilState등의 훅 사용을 통해)
  • atom의 값을 읽는 컴포넌트들은 암묵적으로 atom을 구독하고, 해당 atom에 변화가 생긴다면 이를 구독하는 모든 컴포넌트들이 리렌더링된다.

atom의 기본 형태

atom({key: 'unique key' ,default: 'default state' })로 생성
  • 아톰은 일종의 저장소(store), 그 안에 default state를 포함한다.
  • 아톰은 atom({객체세팅})으로 선언
  • key와 default 프로퍼티는 필수로 선언해야한다.
  • 원시형데이터 타입과 더불어 객체나 배열같은 complex타입도 atom으로 사용할 수 있다.{key: , default: 초기값}

참고: 현재 atom을 설정할 때 Promise을 지정할 수 없다는 점에 유의해야 한다. 비동기 함수를 사용하기 위해서는 selector()를 사용한다.

 

state.js (atom 선언파일)

export const loggedInUserState = atom<LoggedInUser | null>({
  key: 'loggedInUserState', // unique ID (with respect to other atoms/selectors)
  default: null, // initial value (aka initial state)
  effects_UNSTABLE: [loggedInUserPersist], // Apply persistence
});

 

로그인 할 때 setLoggedInUser

// 로그인했을 때
import { setToken } from '../../cookies';
import { getDecodedToken } from '../../tokenDecode';
import { loggedInUserState } from '../../store/loggedInUserAtom';
import { useRecoilValue, useSetRecoilState } from 'recoil';

const LogIn: React.FC = () => {
    const setLoggedInUser = useSetRecoilState(loggedInUserState);

    const onSubmit = async (data: any) => {
        const loginSubmitData = {
          ...data,
          countryId: usableLanguages[selectLanguage].countryId,
        };
        return login('/auth/login', loginSubmitData).then(
          (res: any) => {
            // 받아온 토큰을 쿠키에 저장
            try {
              const { accessToken, refreshToken } = res;
              setToken('accessToken', accessToken);
              setToken('refreshToken', refreshToken);
              const decodedToken: any = getDecodedToken(accessToken);
              const {
                memberId,
                name,
                email,
                companyId,
                companyName,
                companyType,
                role,
                phone,
                accessibleMenuIds,
              } = decodedToken;
              const loggedInUser: LoggedInUser = {
                name,
                companyName,
                companyType,
                memberId,
                email,
                role,
                phone,
                companyId,
                accessibleMenuIds,
              };
              setLoggedInUser(loggedInUser);
            } catch (error) {
              console.error('error', error);
            } finally {
              nav('/');
            }
          },
          (err) => {
            console.error('err', err);
            return err?.response?.data;
          },
        );
      };
      
      return (<></>)
  }

 

Cookie.js

import { useRecoilState } from 'recoil';
import { loggedInUserState } from '../../store/loggedInUserAtom';

const Cookie = () => {
  const [loggedInUser, setLoggedInUser] = useRecoilState(loggedInUserState);

  return (
    <p>{loggedInUser?.name}</p>
  )
}
export default Cookie;

 

useRecoilState

const [cookies, setCookies] = useRecoilState(cookieState);

  • React의 기본 hook인 useState와 굉장히 유사한 형태를 가지고 있다.
  • 구조분해할당으로 atom의 state와 state를 set하는 함수를 각각 받아올 수 있다.
  • atom의 state가 변경되면 이를 구독하는 모든 컴포넌트의 리렌더링이 일어난다.

 

전역상태 관련 Hooks

전역상태(Atoms, Selector)를 get/set 하기 위해 Recoil에서 제공하는 Hooks들을 사용한다. 기본적으로 아래 4가지가 크게 사용된다.
Hook의 인자에는 전역상태인 atom(혹은 Selector)를 넣어준다.(: Recoil에서 atom과 selector는 동일한 훅으로 다뤄준다.

  • useRecoilState() : useState() 와 유사하다.
    전역상태의 state값,setter함수를 반환한다.
`const [state, setState] = useRecoilState(atom|selector)` 형태로 구조분해 할당하여 사용한다.
  • useRecoilValue() : 전역상태의 state상태값만을 반환한다.
  • useSetRecoilState() : 전역상태의 setter 함수만을 반환한다.
  • useResetRecoilState() : 전역상태를 default(초기값)으로 Reset 하기 위해 사용된다.
    reset한 default값이 반환된다.
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { cookieState } from '../../state';

const cookies = useRecoilValue(cookieState);
//state값 
const setCookies = 
useSetRecoilState(cookieState);
//setState함수
const resetCookies = useResetRecoilState(cookieState);
//state초기화후 초기값

 

1. 설치

먼저 react-toastify를 설치해야 합니다. npm 또는 yarn을 사용하여 설치할 수 있습니다.

npm install react-toastify
 
또는
yarn add react-toastify
 
 
 

2. 기본 사용법

설치 후, 다음과 같은 단계를 따라 사용할 수 있습니다.

  • CSS 파일을 가져오기: react-toastify에서 제공하는 기본 스타일을 사용하려면 CSS 파일을 가져와야 합니다.
import 'react-toastify/dist/ReactToastify.css';
  •  ToastContainer 컴포넌트를 추가하기: ToastContainer는 알림이 표시될 영역을 정의합니다. 이 컴포넌트를 루트 컴포넌트나 상위 컴포넌트에 추가합니다.
import React from 'react';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

function App() {
    return (
        <div>
            <ToastContainer />
            <button onClick={() => toast("Hello, world!")}>
                Show Toast
            </button>
        </div>
    );
}

export default App;
 
 
  • 토스트 메시지 표시: 알림을 표시하려면 toast 함수를 호출합니다. 예를 들어 버튼 클릭 시 알림을 표시하려면 다음과 같이 할 수 있습니다.
toast("이것은 기본 토스트 메시지입니다!");
 
 

3. 다양한 옵션 사용하기

react-toastify는 다양한 옵션을 제공하여 토스트 메시지를 커스터마이즈할 수 있습니다.

  • 타입 지정: 성공, 오류, 경고 등의 다양한 유형의 토스트를 만들 수 있습니다.
toast.success("성공 메시지!");
toast.error("에러 메시지!");
toast.warn("경고 메시지!");
toast.info("정보 메시지!");
 
  • 지속 시간 설정: autoClose 속성을 사용하여 토스트가 자동으로 닫히는 시간을 설정할 수 있습니다.
toast("자동 닫힘 설정된 메시지", {
    autoClose: 5000, // 5초 후 자동으로 닫힘
});​
 
 
  • 위치 설정: position 속성을 사용하여 토스트의 위치를 지정할 수 있습니다.
toast("위치 설정된 메시지", {
    position: "top-right",
});
 
  • 닫기 버튼 숨기기: closeOnClick 속성을 false로 설정하여 클릭으로 토스트를 닫지 않도록 할 수 있습니다.
toast("닫기 버튼 숨김", {
    closeOnClick: false,
});

 

전체 예시

import React from 'react';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

function App() {
    const notify = () => {
        toast.success("성공적으로 완료되었습니다!", {
            position: "top-right",
            autoClose: 3000,
            hideProgressBar: false,
            closeOnClick: true,
            pauseOnHover: true,
            draggable: true,
            progress: undefined,
            theme: "colored"
        });
    };

    return (
        <div>
            <button onClick={notify}>토스트 메시지 보여주기</button>
            <ToastContainer />
        </div>
    );
}

export default App;

 

 

1. main.tsx에서 ToastContainer 설정 (전역 적용시)

main.tsx 파일에서는 ToastContainer를 설정하여 애플리케이션의 모든 페이지에서 토스트 메시지를 사용할 수 있도록 해야 합니다. 현재 index.tsx 파일에서 ToastContainer를 추가한 것은 올바른 접근입니다.

(전역 아니면 그냥 index.tsx 파일에 적용)

import { ToastContainer } from 'react-toastify';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <RecoilRoot>
    <Router>
      <ToastContainer />
      <App />
    </Router>
  </RecoilRoot>,
);

 

토스트 커스텀

  • 커스텀
import { toast } from 'react-toastify';

export enum ToastType {
  SUCCESS = 'success',
  ERROR = 'error',
  WARNING = 'warning',
  INFO = 'info',
}

interface ToastProps {
  message: string;
  type: ToastType;
}

// Toast를 컴포넌트가 아닌 함수로 만듭니다.
export const showToast = ({ message, type }: ToastProps) => {
  switch (type) {
    case ToastType.SUCCESS:
      toast.success(message, {
        position: 'top-center',
        autoClose: 1000,
        hideProgressBar: true, // Progress bar 숨김
        closeOnClick: true,
      });
      break;
    case ToastType.ERROR:
      toast.error(message, {
        position: 'top-center',
        autoClose: 1000,
        hideProgressBar: true,
        closeOnClick: true,
      });
      break;
    case ToastType.WARNING:
      toast.warning(message, {
        position: 'top-center',
        autoClose: 1000,
        hideProgressBar: true,
        closeOnClick: true,
      });
      break;
    case ToastType.INFO:
      toast.info(message, {
        position: 'top-center',
        autoClose: 1000,
        hideProgressBar: true,
        closeOnClick: true,
      });
      break;
    default:
      break;
  }
};

 

  • import 커스텀 토스트
import { showToast, ToastType } from './ToastContainer';
import './css/ReactToastify.css';

// POST 요청을 위한 기본 함수
export const postData = async (endpoint: string, data: any) => {
  try {
    const response = await axiosClient.post(`${endpoint}`, data);
    showToast({
      message: 'Successfully processed!',
      type: ToastType.SUCCESS,
    });
    return response.data;
  } catch (error) {
    console.error('Error posting data:', error);
    showToast({
      message: 'Failed to execute request. Please try again.',
      type: ToastType.ERROR,
    });
    throw error;
  }
};

데이터 예시

const newPaymentInfo = {
    paymentList: [
      {
        paymentId: 1,
        companyId: 1,
        companyName: 'ATEMoS',
        meteredUsageId: 1,
        subscriptionServiceList: [
          'AI_ENERGY_USAGE_FORECAST',
          'AI_WATER_USAGE_ANALYSIS',
        ],
        apiCallCount: 50,
        iotInstallationCount: 5,
        storageUsage: 2048000,
        method: 'CARD',
        amount: 25.0,
        status: 'PAID',
        usageDate: '2024-08-25',
        scheduledPaymentDate: '2024-09-05',
        createdDate: '2024-08-26T09:30:00.000Z',
        modifiedDate: '2024-08-26T09:30:00.000Z',
      },
      {
        paymentId: 2,
        companyId: 2,
        companyName: 'GreenTech',
        meteredUsageId: 2,
        subscriptionServiceList: ['AI_SOLAR_POWER_MONITORING'],
        apiCallCount: 80,
        iotInstallationCount: 5,
        storageUsage: 3072000,
        method: 'BANK_TRANSFER',
        amount: 40.0,
        status: 'PAID',
        usageDate: '2024-08-26',
        scheduledPaymentDate: '2024-09-06',
        createdDate: '2024-08-26T10:00:00.000Z',
        modifiedDate: '2024-08-26T10:00:00.000Z',
      },
      {
        paymentId: 3,
        companyId: 3,
        companyName: 'EcoWatt',
        meteredUsageId: 3,
        subscriptionServiceList: [],
        apiCallCount: 5,
        iotInstallationCount: 8,
        storageUsage: 1024000,
        method: 'CASH',
        amount: 10.0,
        status: 'OUTSTANDING',
        usageDate: '2024-08-27',
        scheduledPaymentDate: '2024-09-07',
        createdDate: '2024-08-27T12:00:00.000Z',
        modifiedDate: '2024-08-27T12:00:00.000Z',
      },
      {
        paymentId: 4,
        companyId: 4,
        companyName: 'PowerTech',
        meteredUsageId: 4,
        subscriptionServiceList: [
          'AI_WIND_TURBINE_OPTIMIZATION',
          'AI_ENERGY_STORAGE_MANAGEMENT',
        ],
        apiCallCount: 100,
        iotInstallationCount: 7,
        storageUsage: 4096000,
        method: 'CARD',
        amount: 50.0,
        status: 'PAID',
        usageDate: '2024-08-28',
        scheduledPaymentDate: '2024-09-08',
        createdDate: '2024-08-28T14:00:00.000Z',
        modifiedDate: '2024-08-28T14:00:00.000Z',
      },
    ],
    summaryApiCallCount: 230,
    recentlyIotInstallationCount: 10,
    recentlyStorageUsage: {
      '1': 20480000,
      '2': 3072000,
      '3': 1024000,
      '4': 4096000,
    },
    subscribedCount: {
      '1': { AI_ENERGY_USAGE_FORECAST: 1, AI_WATER_USAGE_ANALYSIS: 1 },
      '2': { AI_SOLAR_POWER_MONITORING: 1 },
      '4': { AI_WIND_TURBINE_OPTIMIZATION: 1, AI_ENERGY_STORAGE_MANAGEMENT: 1 },
    },
    summaryAmount: 125.0,
    totalElements: 4,
    totalPages: 1,
  };

 

 

기존 React-calendar 코드

// 기존 React-calendar 코드
import ai from '../../images/icon/ai_icon.png';
import api from '../../images/icon/api_icon.png';
import facilities from '../../images/icon/facilities_icon.png';
import storage from '../../images/icon/storage_icon.png';
import Calendar from 'react-calendar';
// import 'react-calendar/dist/Calendar.css';
import '../../css/custom-calendar.css'; // 커스텀 스타일 파일 import
import { useEffect, useState } from 'react';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import Breadcrumb from '../../components/Breadcrumbs/Breadcrumb';
import { fetchData } from '../../api';
import { loggedInUserState } from '../../store/loggedInUserAtom';
import { useRecoilValue } from 'recoil';

const Subscription = () => {
  const loggedInUser = useRecoilValue(loggedInUserState);

  const [currentMonth, setCurrentMonth] = useState(new Date());
  const [paymentInfo, setPaymentInfo] = useState({
    paymentList: [],
    summaryApiCallCount: 0,
    recentlyIotInstallationCount: 0,
    recentlyStorageUsage: {},
    subscribedCount: {},
  });

  // 현재 달의 1일과 말일 계산
  const startOfCurrentMonth = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
  const endOfCurrentMonth = format(endOfMonth(currentMonth), 'yyyy-MM-dd');

  const handlePrevMonth = () => {
    setCurrentMonth((prev) => new Date(prev.setMonth(prev.getMonth() - 1)));
  };

  const handleNextMonth = () => {
    setCurrentMonth((prev) => new Date(prev.setMonth(prev.getMonth() + 1)));
  };

  const getMonthYear = (date: any) => {
    return format(date, 'MMMM yyyy').toUpperCase();
  };

  const fetchPaymentInfo = async () => {
    if (loggedInUser && loggedInUser.memberId && loggedInUser.companyId) {
      try {
        // API 호출을 통해 payment 정보 가져오기
        const data = await fetchData(
          /payment?companyId=${loggedInUser.companyId}&usageDateStart=${startOfCurrentMonth}&usageDateEnd=${endOfCurrentMonth},
        );
        setPaymentInfo(data.data);
      } catch (error) {
        console.error('Error while fetching payment info:', error);
      }
    }
  };

  useEffect(() => {
    // fetchPaymentInfo();
    setPaymentInfo(newPaymentInfo);
  }, [currentMonth]);

  // usageDate를 키로 하는 apiCallCount 매핑 생성
  const apiCallCounts = paymentInfo.paymentList.reduce(
    (acc, payment: any) => {
      const date = new Date(payment.usageDate).toDateString(); // 날짜를 문자열로 변환
      acc[date] = (acc[date] || 0) + payment.apiCallCount;
      return acc;
    },
    {} as Record<string, number>,
  );

  // 합산 Ai
  const getTotalSubscriptionCount = () => {
    return paymentInfo.paymentList.reduce(
      (total: number, payment: any) =>
        payment.subscriptionServiceList.length > 0 ? total + 1 : total,
      0,
    );
  };

  // 합산 ApiCall
  const getTotalApiCallCount = () => {
    return paymentInfo.paymentList.reduce(
      (total, payment: any) => total + payment.apiCallCount,
      0,
    );
  };

  // 합산 IotInstallation
  const getTotalIotInstallationCount = () => {
    // paymentList를 날짜 기준으로 정렬
    const sortedList = paymentInfo.paymentList.sort(
      (a, b) =>
        new Date(b.usageDate).getTime() - new Date(a.usageDate).getTime(),
    );

    // 가장 최근 항목의 iotInstallationCount 반환
    return sortedList[0]?.iotInstallationCount || 0;
  };

  // 합산 Storage
  // recentlyStorageUsage 객체의 첫 번째 키를 동적으로 추출
  const storageKey = Object.keys(paymentInfo.recentlyStorageUsage)[0];

  // 해당 키의 값을 기가바이트로 변환
  const storageUsageInGB = storageKey
    ? paymentInfo.recentlyStorageUsage[storageKey] / (1024 * 1024 * 1024)
    : 0;

  return (
    <div className="w-full">
      <Breadcrumb pageName="Service Usage History" />

      <div className="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
        <div className="flex items-center justify-between px-7.5 pt-7.5">
          <h3 className="text-lg font-semibold text-black dark:text-white">
            {getMonthYear(currentMonth)}
          </h3>
          <div>
            <button
              onClick={handlePrevMonth}
              className="mr-2 rounded border border-stroke p-1 text-xl font-bold shadow-card-2 hover:bg-meta-2
            "
            >
              <svg
                width="18"
                height="18"
                viewBox="0 0 18 18"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
                style={{ transform: 'rotate(90deg)' }}
              >
                <path
                  d="M9.0002 12.825C8.83145 12.825 8.69082 12.7688 8.5502 12.6563L2.08145 6.30002C1.82832 6.0469 1.82832 5.65315 2.08145 5.40002C2.33457 5.1469 2.72832 5.1469 2.98145 5.40002L9.0002 11.2781L15.0189 5.34377C15.2721 5.09065 15.6658 5.09065 15.9189 5.34377C16.1721 5.5969 16.1721 5.99065 15.9189 6.24377L9.45019 12.6C9.30957 12.7406 9.16895 12.825 9.0002 12.825Z"
                  fill="#64748B"
                />
              </svg>
            </button>
            <button
              onClick={handleNextMonth}
              className="rounded border border-stroke p-1 text-xl font-bold shadow-card-2 hover:bg-meta-2"
            >
              <svg
                width="18"
                height="18"
                viewBox="0 0 18 18"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
                style={{ transform: 'rotate(-90deg)' }}
              >
                <path
                  d="M9.0002 12.825C8.83145 12.825 8.69082 12.7688 8.5502 12.6563L2.08145 6.30002C1.82832 6.0469 1.82832 5.65315 2.08145 5.40002C2.33457 5.1469 2.72832 5.1469 2.98145 5.40002L9.0002 11.2781L15.0189 5.34377C15.2721 5.09065 15.6658 5.09065 15.9189 5.34377C16.1721 5.5969 16.1721 5.99065 15.9189 6.24377L9.45019 12.6C9.30957 12.7406 9.16895 12.825 9.0002 12.825Z"
                  fill="#64748B"
                />
              </svg>
            </button>
          </div>
        </div>
        <div className="grid grid-cols-1 gap-4 p-7.5 md:grid-cols-4">
          <div className="col-span-1 rounded border border-stroke bg-white dark:border-strokedark dark:bg-boxdark lg:col-span-1  md:col-span-2 xl:col-span-3 ">
            <Calendar
              calendarType="gregory"
              value={currentMonth}
              onChange={(newDate: any) => setCurrentMonth(newDate)}
              showNeighboringMonth={true}
              maxDetail="month"
              minDetail="month"
              formatShortWeekday={(locale: any, date) => {
                const weekdays = [
                  'SUN',
                  'MON',
                  'TUE',
                  'WED',
                  'THU',
                  'FRI',
                  'SAT',
                ];
                return weekdays[date.getDay()];
              }}
              formatDay={(locale: any, date) => date.getDate().toString()} // 날짜 포맷 수정
              tileContent={({ date }) => {
                const dateKey = new Date(date).toDateString();
                const currentDate = date.setHours(0, 0, 0, 0);

                // API 호출 수
                const apiCallCount = apiCallCounts[dateKey] || 0;

                // AI 서비스 사용 여부 확인 (특정 날짜만 체크)
                const isAnyServiceUsed = paymentInfo.paymentList.some(
                  (payment: any) =>
                    new Date(payment.usageDate).toDateString() === dateKey &&
                    payment.subscriptionServiceList.length > 0,
                );

                return (
                  <div className="flex w-full flex-col gap-1">
                    {apiCallCount > 0 && (
                      <div className="dot api">
                        <span className="mr-1">API</span>
                        {apiCallCount}
                      </div>
                    )}
                    {isAnyServiceUsed && (
                      <div className="dot ai">
                        <span className="mr-1">AI</span>
                      </div>
                    )}
                  </div>
                );
              }}
            />
          </div>
          <ul className="col-span-1 flex flex-col gap-4 md:col-span-2 xl:col-span-1">
            <li className="flex items-center justify-between rounded border border-stroke bg-white p-5 dark:border-strokedark dark:bg-boxdark">
              <div className="flex items-center gap-4">
                <p className="flex h-10 w-10 items-center justify-center rounded-full bg-meta-2">
                  <img src={ai} alt="Logo" />
                </p>
                <p className="text-lg font-semibold text-black dark:text-white">
                  AI Usage
                </p>
              </div>
              <p className="font-semibold text-black dark:text-white">
                {getTotalSubscriptionCount()}
                <span className="ml-0.5 text-sm">days</span>
              </p>
            </li>
            <li className="flex items-center justify-between rounded border border-stroke bg-white p-5 dark:border-strokedark dark:bg-boxdark">
              <div className="flex items-center gap-4">
                <p className="flex h-10 w-10 items-center justify-center rounded-full bg-meta-2">
                  <img src={api} alt="Logo" />
                </p>
                <p className="text-lg font-semibold text-black dark:text-white">
                  API Usage
                </p>
              </div>
              <p className="font-semibold text-black dark:text-white">
                {getTotalApiCallCount()}
                <span className="ml-0.5 text-sm"></span>
              </p>
            </li>
            <li className="flex items-center justify-between rounded border border-stroke bg-white p-5 dark:border-strokedark dark:bg-boxdark">
              <div className="flex items-center gap-4">
                <p className="flex h-10 w-10 items-center justify-center rounded-full bg-meta-2">
                  <img
                    src={facilities}
                    alt="
                  go"
                  />
                </p>
                <p className="text-lg font-semibold text-black dark:text-white">
                  facilities
                </p>
              </div>
              <p className="font-semibold text-black dark:text-white">
                {getTotalIotInstallationCount()}
                <span className="ml-0.5 text-sm"></span>
              </p>
            </li>
            <li className="flex items-center justify-between rounded border border-stroke bg-white p-5 dark:border-strokedark dark:bg-boxdark">
              <div className="flex items-center gap-4">
                <p className="flex h-10 w-10 items-center justify-center rounded-full bg-meta-2">
                  <img src={storage} alt="Logo" />
                </p>
                <p className="text-lg font-semibold text-black dark:text-white">
                  Storage
                </p>
              </div>
              <p className="font-semibold text-black dark:text-white">
                {storageUsageInGB < 0.01
                  ? ${storageUsageInGB.toFixed(3)}
                  : ${storageUsageInGB.toFixed(2)}}
                <span className="ml-0.5 text-sm">GB</span>
              </p>
            </li>
          </ul>
        </div>

        <div className="px-7.5 pb-7.5 pt-2.5">
          <h3 className="text-lg font-semibold text-black dark:text-white">
            {loggedInUser?.companyName} / {loggedInUser?.companyType}
          </h3>
          <div className="pt-7.5">
            <table className="w-full leading-loose">
              <tbody className="w-full">
                <tr className="h-10 w-full border-b border-t border-stroke">
                  <td className="w-3/12">AI Usage</td>
                  <td className="hidden w-5/12 md:block">
                    {startOfCurrentMonth} ~ {endOfCurrentMonth}
                  </td>
                  <td className="w-2/12">
                    {getTotalSubscriptionCount()}
                    <span className="ml-0.5">days</span>
                  </td>
                  <td className="w-2/12 text-right">$120.00</td>
                </tr>
                <tr className="h-10 w-full border-b border-stroke">
                  <td className="w-3/12">API Usage</td>
                  <td className="hidden w-5/12 md:block">
                    {startOfCurrentMonth} ~ {endOfCurrentMonth}
                  </td>
                  <td className="w-2/12">
                    {getTotalApiCallCount()}
                    <span className="ml-0.5"></span>
                  </td>
                  <td className="w-2/12 text-right">$120.00</td>
                </tr>
                <tr className="h-10 w-full border-b border-stroke">
                  <td className="w-3/12">facilities</td>
                  <td className="hidden w-5/12 md:block">
                    {startOfCurrentMonth} ~ {endOfCurrentMonth}
                  </td>
                  <td className="w-2/12">
                    {getTotalIotInstallationCount()}
                    <span className="ml-0.5"></span>
                  </td>
                  <td className="w-2/12 text-right">$120.00</td>
                </tr>
                <tr className="h-10 w-full border-b border-stroke">
                  <td className="w-3/12">Storage</td>
                  <td className="hidden w-5/12 md:block">
                    {startOfCurrentMonth} ~ {endOfCurrentMonth}
                  </td>
                  <td className="w-2/12">
                    {storageUsageInGB < 0.01
                      ? ${storageUsageInGB.toFixed(3)}
                      : ${storageUsageInGB.toFixed(2)}}
                    <span className="ml-0.5">GB</span>
                  </td>
                  <td className="w-2/12 text-right">$120.00</td>
                </tr>
              </tbody>
            </table>
          </div>

          <table className="w-full leading-loose">
            <tbody className="w-full">
              <tr className="h-10 w-full">
                <td className="hidden w-8/12 md:block"></td>
                <td className="w-2/12">Subtotal</td>
                <td className="w-2/12 text-right">$120.00</td>
              </tr>
              <tr className="h-10 w-full">
                <td className="hidden w-8/12 md:block"></td>
                <td className="w-2/12 border-b border-primary">Duty (+)</td>
                <td className="w-2/12 border-b border-primary text-right">
                  $10.00
                </td>
              </tr>
              <tr className="h-10 w-full font-medium text-primary">
                <td className="hidden w-8/12 md:block"></td>
                <td className="w-2/12">Total</td>
                <td className="w-2/12 text-right">$130.00</td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
};

export default Subscription;

 

설치

npm install react-big-calendar

 

npm install date-fns

 

import 

import moment from 'moment'; // moment 추가
import { Calendar, momentLocalizer } from 'react-big-calendar';
import { format, parseISO, startOfMonth, endOfMonth } from 'date-fns';
import 'react-big-calendar/lib/css/react-big-calendar.css';

 

const localizer = momentLocalizer(moment);

const Subscription = () => {
  const [events, setEvents] = useState([]);
  
  const fetchPaymentInfo = async () => {
    if (loggedInUser && loggedInUser.memberId && loggedInUser.companyId) {
      try {
        // API call to get payment data
        const data = await fetchData(
          `/payment?companyId=${loggedInUser.companyId}&usageDateStart=${startOfCurrentMonth}&usageDateEnd=${endOfCurrentMonth}`
        );
        const paymentList = data.data.paymentList;
        
        const events = paymentList
        .filter((payment) => payment.subscriptionServiceList.length > 0) // 서비스가 있는 경우만 필터링
        .map((payment) => ({
          start: parseISO(payment.usageDate),
          end: parseISO(payment.usageDate),
          title: 'AI', // 서비스가 있을 때는 'AI'
          allDay: true,
          resource: payment,
        }));
        
        setEvents(events);
      } catch (error) {
        console.error('Error fetching payment info:', error);
      }
    }
  };
}

  useEffect(() => {
    fetchPaymentInfo();
  }, [currentMonth]);
  
   // Function to generate a set of continuous events with the same title
  const getContinuousEvents = (events) => {
    const sortedEvents = events.sort((a, b) => a.start - b.start);
    const continuousEvents = [];
    let currentEvent = null;

    sortedEvents.forEach(event => {
      if (currentEvent) {
        const lastEventDate = new Date(currentEvent.end);
        const currentEventDate = new Date(event.start);
        if (currentEvent.title === event.title && currentEventDate <= lastEventDate) {
          currentEvent.end = currentEvent.end > event.end ? currentEvent.end : event.end;
        } else {
          continuousEvents.push(currentEvent);
          currentEvent = event;
        }
      } else {
        currentEvent = event;
      }
    });

    if (currentEvent) {
      continuousEvents.push(currentEvent);
    }

    return continuousEvents;
  const continuousEvents = getContinuousEvents(events);

  return (
    <div style={{ height: '100vh' }}>
      <Calendar
        localizer={localizer}
        events={continuousEvents}
        startAccessor="start"
        endAccessor="end"
        style={{ height: 500 }}
        views={['month']}
        onNavigate={(date) => setCurrentMonth(date)}
      />
    </div>
  );
};

export default Subscription;

 

 

연속된 날짜로 배열 표시하려면 아래 함수 추가

const mergeContinuousEvents = (events) => {
  if (events.length === 0) return [];

  const sortedEvents = events.sort((a, b) => a.start.getTime() - b.start.getTime());
  const mergedEvents = [];
  let currentEvent = sortedEvents[0];

  for (let i = 1; i < sortedEvents.length; i++) {
    const nextEvent = sortedEvents[i];

    if (
      currentEvent.end.getTime() >= nextEvent.start.getTime() - 24 * 60 * 60 * 1000 &&
      currentEvent.title === nextEvent.title
    ) {
      currentEvent.end = new Date(
        Math.max(currentEvent.end.getTime(), nextEvent.end.getTime())
      );
    } else {
      // Adjust end date to include the entire day
      currentEvent.end = new Date(currentEvent.end.getTime() + 24 * 60 * 60 * 1000);
      mergedEvents.push(currentEvent);
      currentEvent = nextEvent;
    }
  }

  currentEvent.end = new Date(currentEvent.end.getTime() + 24 * 60 * 60 * 1000);
  mergedEvents.push(currentEvent);

  return mergedEvents;
};

 

아래처럼 수정

useEffect(() => {
  const paymentList = newPaymentInfo.paymentList;
  const events = paymentList
    .filter((payment) => payment.subscriptionServiceList.length > 0)
    .map((payment) => ({
      start: parseISO(payment.usageDate),
      end: parseISO(payment.usageDate),
      title: 'AI',
      allDay: true,
      resource: payment,
    }));

  const mergedEvents = mergeContinuousEvents(events);
  setEvents(mergedEvents);

  console.log(mergedEvents);
}, [currentMonth]);

 

월 필터 핸들러 기존 버튼에서 다루게 하기

import React, { useState, useEffect } from 'react';
import { Calendar as BigCalendar, momentLocalizer } from 'react-big-calendar';
import moment from 'moment';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import { format, startOfMonth, endOfMonth, parseISO, addMonths, subMonths } from 'date-fns';

const localizer = momentLocalizer(moment);

const MyCustomCalendar = ({ newPaymentInfo }) => {
  const [currentMonth, setCurrentMonth] = useState(new Date());
  const [events, setEvents] = useState([]);

  // 현재 달의 1일과 말일 계산
  const startOfCurrentMonth = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
  const endOfCurrentMonth = format(endOfMonth(currentMonth), 'yyyy-MM-dd');

  // 이벤트 생성 함수
  const generateEvents = () => {
    const paymentList = newPaymentInfo.paymentList;

    const aiEvents = paymentList
      .filter((payment) => payment.subscriptionServiceList.length > 0)
      .map((payment) => ({
        start: parseISO(payment.usageDate),
        end: parseISO(payment.usageDate),
        title: 'AI',
        allDay: true,
        resource: payment,
      }));

    const apiEvents = paymentList
      .filter((payment) => payment.apiCallCount > 0)
      .map((payment) => ({
        start: parseISO(payment.usageDate),
        end: parseISO(payment.usageDate),
        title: 'API',
        allDay: true,
        resource: payment,
      }));

    // 이벤트 병합 및 설정
    const mergedAIEvents = mergeAIEvents(aiEvents); // 기존의 mergeAIEvents 사용
    setEvents([...mergedAIEvents, ...apiEvents]);
  };

  useEffect(() => {
    generateEvents();
  }, [currentMonth, newPaymentInfo]); // currentMonth와 newPaymentInfo에 의존

  // 월 변경 처리 함수
  const handlePrevMonth = () => {
    setCurrentMonth((prev) => subMonths(prev, 1));
  };

  const handleNextMonth = () => {
    setCurrentMonth((prev) => addMonths(prev, 1));
  };

  return (
    <div>
      <div className="flex justify-between mb-4">
        <button
          onClick={handlePrevMonth}
          className="mr-2 rounded border border-stroke p-1 text-xl font-bold shadow-card-2 hover:bg-meta-2"
        >
          <svg
            width="18"
            height="18"
            viewBox="0 0 18 18"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
            style={{ transform: 'rotate(90deg)' }}
          >
            <path
              d="M9.0002 12.825C8.83145 12.825 8.69082 12.7688 8.5502 12.6563L2.08145 6.30002C1.82832 6.0469 1.82832 5.65315 2.08145 5.40002C2.33457 5.1469 2.72832 5.1469 2.98145 5.40002L9.0002 11.2781L15.0189 5.34377C15.2721 5.09065 15.6658 5.09065 15.9189 5.34377C16.1721 5.5969 16.1721 5.99065 15.9189 6.24377L9.45019 12.6C9.30957 12.7406 9.16895 12.825 9.0002 12.825Z"
              fill="#64748B"
            />
          </svg>
        </button>
        <button
          onClick={handleNextMonth}
          className="rounded border border-stroke p-1 text-xl font-bold shadow-card-2 hover:bg-meta-2"
        >
          <svg
            width="18"
            height="18"
            viewBox="0 0 18 18"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
            style={{ transform: 'rotate(-90deg)' }}
          >
            <path
              d="M9.0002 12.825C8.83145 12.825 8.69082 12.7688 8.5502 12.6563L2.08145 6.30002C1.82832 6.0469 1.82832 5.65315 2.08145 5.40002C2.33457 5.1469 2.72832 5.1469 2.98145 5.40002L9.0002 11.2781L15.0189 5.34377C15.2721 5.09065 15.6658 5.09065 15.9189 5.34377C16.1721 5.5969 16.1721 5.99065 15.9189 6.24377L9.45019 12.6C9.30957 12.7406 9.16895 12.825 9.0002 12.825Z"
              fill="#64748B"
            />
          </svg>
        </button>
      </div>

      <BigCalendar
        localizer={localizer}
        events={events}
        startAccessor="start"
        endAccessor="end"
        style={{ height: 500 }}
        views={['month']}
        date={currentMonth} // 현재 월을 표시
        className="rbc-calendar"
      />
    </div>
  );
};

export default MyCustomCalendar;

 

 

최종 코드

import ai from '../../images/icon/ai_icon.png';
import api from '../../images/icon/api_icon.png';
import facilities from '../../images/icon/facilities_icon.png';
import storage from '../../images/icon/storage_icon.png';
import { useEffect, useState } from 'react';
import { format, parseISO, startOfMonth, endOfMonth } from 'date-fns';
import Breadcrumb from '../../components/Breadcrumbs/Breadcrumb';
import { fetchData } from '../../api';
import { loggedInUserState } from '../../store/loggedInUserAtom';
import { useRecoilValue } from 'recoil';

import moment from 'moment'; // moment 추가
import { Calendar, momentLocalizer } from 'react-big-calendar';
import '../../css/react-big-calendar.css';

const localizer = momentLocalizer(moment);

const Subscription = () => {
  const loggedInUser = useRecoilValue(loggedInUserState);

  const [currentMonth, setCurrentMonth] = useState(new Date());
  const [paymentInfo, setPaymentInfo] = useState({
    paymentList: [],
    summaryApiCallCount: 0,
    recentlyIotInstallationCount: 0,
    recentlyStorageUsage: {},
    subscribedCount: {},
  });

  const [events, setEvents] = useState([]);

  // 현재 달의 1일과 말일 계산
  const startOfCurrentMonth = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
  const endOfCurrentMonth = format(endOfMonth(currentMonth), 'yyyy-MM-dd');

  const handlePrevMonth = () => {
    setCurrentMonth((prev) => new Date(prev.setMonth(prev.getMonth() - 1)));
  };

  const handleNextMonth = () => {
    setCurrentMonth((prev) => new Date(prev.setMonth(prev.getMonth() + 1)));
  };

  const getMonthYear = (date: any) => {
    return format(date, 'MMMM yyyy').toUpperCase();
  };

  const fetchPaymentInfo = async () => {
    if (loggedInUser && loggedInUser.memberId && loggedInUser.companyId) {
      try {
        // API 호출을 통해 payment 정보 가져오기
        const data = await fetchData(
          `/payment?companyId=${loggedInUser.companyId}&usageDateStart=${startOfCurrentMonth}&usageDateEnd=${endOfCurrentMonth}`,
        );
        setPaymentInfo(data.data);
      } catch (error) {
        console.error('Error while fetching payment info:', error);
      }
    }
  };

  const mergeAIEvents = (events: any) => {
    if (events.length === 0) return [];

    const sortedEvents = events.sort(
      (a, b) => a.start.getTime() - b.start.getTime(),
    );
    const mergedEvents = [];
    let currentEvent = sortedEvents[0];

    for (let i = 1; i < sortedEvents.length; i++) {
      const nextEvent = sortedEvents[i];

      // 연속된 날짜이고, 같은 제목의 이벤트일 경우
      if (
        currentEvent.end.getTime() >=
          nextEvent.start.getTime() - 24 * 60 * 60 * 1000 &&
        currentEvent.title === nextEvent.title
      ) {
        currentEvent.end = new Date(
          Math.max(currentEvent.end.getTime(), nextEvent.end.getTime()),
        );
      } else {
        // 종료 날짜를 포함시키기 위해 1일 추가
        currentEvent.end = new Date(
          currentEvent.end.getTime() + 24 * 60 * 60 * 1000,
        );
        mergedEvents.push(currentEvent);
        currentEvent = nextEvent;
      }
    }

    currentEvent.end = new Date(
      currentEvent.end.getTime() + 24 * 60 * 60 * 1000,
    );
    mergedEvents.push(currentEvent);

    return mergedEvents;
  };

  const generateEvents = () => {
    const paymentList = paymentInfo.paymentList;

    const aiEvents = paymentList
      .filter((payment) => payment.subscriptionServiceList.length > 0)
      .map((payment) => ({
        start: parseISO(payment.usageDate),
        end: parseISO(payment.usageDate),
        title: 'AI',
        allDay: true,
        resource: payment,
        type: 'ai', // 추가 속성
      }));

    const apiEvents = paymentList
      .filter((payment) => payment.apiCallCount > 0)
      .map((payment) => ({
        start: parseISO(payment.usageDate),
        end: parseISO(payment.usageDate),
        title: `API ${payment.apiCallCount}`, // API 호출 수를 포함
        allDay: true,
        resource: payment,
        type: 'api', // 추가 속성
      }));

    const mergedAIEvents = mergeAIEvents(aiEvents);
    setEvents([...mergedAIEvents, ...apiEvents]);
  };

  useEffect(() => {
    fetchPaymentInfo();
  }, [currentMonth]);

  useEffect(() => {
    generateEvents();
  }, [paymentInfo]); // paymentInfo가 업데이트될 때마다 generateEvents 호출

  // 이벤트 스타일 설정 함수
  const eventStyleGetter = (event) => {
    let className = '';

    switch (event.type) {
      case 'ai':
        className = 'ai';
        break;
      case 'api':
        className = 'api';
        break;
      default:
        className = '';
        break;
    }

    return {
      className: `rbc-event ${className}`, // 클래스명 설정
    };
  };

  // usageDate를 키로 하는 apiCallCount 매핑 생성
  const apiCallCounts = paymentInfo.paymentList.reduce(
    (acc, payment: any) => {
      const date = new Date(payment.usageDate).toDateString(); // 날짜를 문자열로 변환
      acc[date] = (acc[date] || 0) + payment.apiCallCount;
      return acc;
    },
    {} as Record<string, number>,
  );

  // 합산 Ai
  const getTotalSubscriptionCount = () => {
    return paymentInfo.paymentList.reduce(
      (total: number, payment: any) =>
        payment.subscriptionServiceList.length > 0 ? total + 1 : total,
      0,
    );
  };

  // 합산 ApiCall
  const getTotalApiCallCount = () => {
    return paymentInfo.paymentList.reduce(
      (total, payment: any) => total + payment.apiCallCount,
      0,
    );
  };

  // 합산 IotInstallation
  const getTotalIotInstallationCount = () => {
    // paymentList를 날짜 기준으로 정렬
    const sortedList = paymentInfo.paymentList.sort(
      (a, b) =>
        new Date(b.usageDate).getTime() - new Date(a.usageDate).getTime(),
    );

    // 가장 최근 항목의 iotInstallationCount 반환
    return sortedList[0]?.iotInstallationCount || 0;
  };

  // 합산 Storage
  // recentlyStorageUsage 객체의 첫 번째 키를 동적으로 추출
  const storageKey = Object.keys(paymentInfo.recentlyStorageUsage)[0];

  // 해당 키의 값을 기가바이트로 변환
  const storageUsageInGB = storageKey
    ? paymentInfo.recentlyStorageUsage[storageKey] / (1024 * 1024 * 1024)
    : 0;

  return (
    <div className="w-full">
      <Breadcrumb pageName="Service Usage History" />

      <div className="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
        <div className="flex items-center justify-between px-7.5 pt-7.5">
          <h3 className="text-lg font-semibold text-black dark:text-white">
            {getMonthYear(currentMonth)}
          </h3>
          <div>
            <button
              onClick={handlePrevMonth}
              className="mr-2 rounded border border-stroke p-1 text-xl font-bold shadow-card-2 hover:bg-meta-2
            "
            >
              <svg
                width="18"
                height="18"
                viewBox="0 0 18 18"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
                style={{ transform: 'rotate(90deg)' }}
              >
                <path
                  d="M9.0002 12.825C8.83145 12.825 8.69082 12.7688 8.5502 12.6563L2.08145 6.30002C1.82832 6.0469 1.82832 5.65315 2.08145 5.40002C2.33457 5.1469 2.72832 5.1469 2.98145 5.40002L9.0002 11.2781L15.0189 5.34377C15.2721 5.09065 15.6658 5.09065 15.9189 5.34377C16.1721 5.5969 16.1721 5.99065 15.9189 6.24377L9.45019 12.6C9.30957 12.7406 9.16895 12.825 9.0002 12.825Z"
                  fill="#64748B"
                />
              </svg>
            </button>
            <button
              onClick={handleNextMonth}
              className="rounded border border-stroke p-1 text-xl font-bold shadow-card-2 hover:bg-meta-2"
            >
              <svg
                width="18"
                height="18"
                viewBox="0 0 18 18"
                fill="none"
                xmlns="http://www.w3.org/2000/svg"
                style={{ transform: 'rotate(-90deg)' }}
              >
                <path
                  d="M9.0002 12.825C8.83145 12.825 8.69082 12.7688 8.5502 12.6563L2.08145 6.30002C1.82832 6.0469 1.82832 5.65315 2.08145 5.40002C2.33457 5.1469 2.72832 5.1469 2.98145 5.40002L9.0002 11.2781L15.0189 5.34377C15.2721 5.09065 15.6658 5.09065 15.9189 5.34377C16.1721 5.5969 16.1721 5.99065 15.9189 6.24377L9.45019 12.6C9.30957 12.7406 9.16895 12.825 9.0002 12.825Z"
                  fill="#64748B"
                />
              </svg>
            </button>
          </div>
        </div>
        <div className="grid grid-cols-1 gap-4 p-7.5 md:grid-cols-4">
          <div className="col-span-1 bg-white dark:bg-boxdark lg:col-span-1  md:col-span-2 xl:col-span-3">
            <Calendar
              localizer={localizer}
              events={events}
              startAccessor="start"
              endAccessor="end"
              style={{ height: 500 }}
              views={['month']}
              date={currentMonth}
              eventPropGetter={eventStyleGetter}
              className="rbc-calendar"
            />
          </div>
          <ul className="col-span-1 flex flex-col gap-4 md:col-span-2 xl:col-span-1">
            <li className="flex items-center justify-between rounded border border-stroke bg-white p-5 dark:border-strokedark dark:bg-boxdark">
              <div className="flex items-center gap-4">
                <p className="flex h-10 w-10 items-center justify-center rounded-full bg-meta-2">
                  <img src={ai} alt="Logo" />
                </p>
                <p className="text-lg font-semibold text-black dark:text-white">
                  AI Usage
                </p>
              </div>
              <p className="font-semibold text-black dark:text-white">
                {getTotalSubscriptionCount()}
                <span className="ml-0.5 text-sm">days</span>
              </p>
            </li>
            <li className="flex items-center justify-between rounded border border-stroke bg-white p-5 dark:border-strokedark dark:bg-boxdark">
              <div className="flex items-center gap-4">
                <p className="flex h-10 w-10 items-center justify-center rounded-full bg-meta-2">
                  <img src={api} alt="Logo" />
                </p>
                <p className="text-lg font-semibold text-black dark:text-white">
                  API Usage
                </p>
              </div>
              <p className="font-semibold text-black dark:text-white">
                {getTotalApiCallCount()}
                <span className="ml-0.5 text-sm"></span>
              </p>
            </li>
            <li className="flex items-center justify-between rounded border border-stroke bg-white p-5 dark:border-strokedark dark:bg-boxdark">
              <div className="flex items-center gap-4">
                <p className="flex h-10 w-10 items-center justify-center rounded-full bg-meta-2">
                  <img
                    src={facilities}
                    alt="
                  go"
                  />
                </p>
                <p className="text-lg font-semibold text-black dark:text-white">
                  facilities
                </p>
              </div>
              <p className="font-semibold text-black dark:text-white">
                {getTotalIotInstallationCount()}
                <span className="ml-0.5 text-sm"></span>
              </p>
            </li>
            <li className="flex items-center justify-between rounded border border-stroke bg-white p-5 dark:border-strokedark dark:bg-boxdark">
              <div className="flex items-center gap-4">
                <p className="flex h-10 w-10 items-center justify-center rounded-full bg-meta-2">
                  <img src={storage} alt="Logo" />
                </p>
                <p className="text-lg font-semibold text-black dark:text-white">
                  Storage
                </p>
              </div>
              <p className="font-semibold text-black dark:text-white">
                {storageUsageInGB < 0.01
                  ? `${storageUsageInGB.toFixed(3)}`
                  : `${storageUsageInGB.toFixed(2)}`}
                <span className="ml-0.5 text-sm">GB</span>
              </p>
            </li>
          </ul>
        </div>

        <div className="px-7.5 pb-7.5 pt-2.5">
          <h3 className="text-lg font-semibold text-black dark:text-white">
            {loggedInUser?.companyName} / {loggedInUser?.companyType}
          </h3>
          <div className="pt-7.5">
            <table className="w-full leading-loose">
              <tbody className="w-full">
                <tr className="h-10 w-full border-b border-t border-stroke">
                  <td className="w-3/12">AI Usage</td>
                  <td className="hidden w-5/12 md:block">
                    {startOfCurrentMonth} ~ {endOfCurrentMonth}
                  </td>
                  <td className="w-2/12">
                    {getTotalSubscriptionCount()}
                    <span className="ml-0.5">days</span>
                  </td>
                  <td className="w-2/12 text-right">$120.00</td>
                </tr>
                <tr className="h-10 w-full border-b border-stroke">
                  <td className="w-3/12">API Usage</td>
                  <td className="hidden w-5/12 md:block">
                    {startOfCurrentMonth} ~ {endOfCurrentMonth}
                  </td>
                  <td className="w-2/12">
                    {getTotalApiCallCount()}
                    <span className="ml-0.5"></span>
                  </td>
                  <td className="w-2/12 text-right">$120.00</td>
                </tr>
                <tr className="h-10 w-full border-b border-stroke">
                  <td className="w-3/12">facilities</td>
                  <td className="hidden w-5/12 md:block">
                    {startOfCurrentMonth} ~ {endOfCurrentMonth}
                  </td>
                  <td className="w-2/12">
                    {getTotalIotInstallationCount()}
                    <span className="ml-0.5"></span>
                  </td>
                  <td className="w-2/12 text-right">$120.00</td>
                </tr>
                <tr className="h-10 w-full border-b border-stroke">
                  <td className="w-3/12">Storage</td>
                  <td className="hidden w-5/12 md:block">
                    {startOfCurrentMonth} ~ {endOfCurrentMonth}
                  </td>
                  <td className="w-2/12">
                    {storageUsageInGB < 0.01
                      ? `${storageUsageInGB.toFixed(3)}`
                      : `${storageUsageInGB.toFixed(2)}`}
                    <span className="ml-0.5">GB</span>
                  </td>
                  <td className="w-2/12 text-right">$120.00</td>
                </tr>
              </tbody>
            </table>
          </div>

          <table className="w-full leading-loose">
            <tbody className="w-full">
              <tr className="h-10 w-full">
                <td className="hidden w-8/12 md:block"></td>
                <td className="w-2/12">Subtotal</td>
                <td className="w-2/12 text-right">$120.00</td>
              </tr>
              <tr className="h-10 w-full">
                <td className="hidden w-8/12 md:block"></td>
                <td className="w-2/12 border-b border-primary">Duty (+)</td>
                <td className="w-2/12 border-b border-primary text-right">
                  $10.00
                </td>
              </tr>
              <tr className="h-10 w-full font-medium text-primary">
                <td className="hidden w-8/12 md:block"></td>
                <td className="w-2/12">Total</td>
                <td className="w-2/12 text-right">$130.00</td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
};

export default Subscription;

'Framework > React' 카테고리의 다른 글

[React] React를 위한 상태관리 라이브러리 - Recoil  (1) 2024.09.04
[React] react-toastify custom  (0) 2024.09.04
[React] api 연결  (0) 2024.07.18
[React] 로딩 중  (0) 2024.07.09
[React] 웹 스토리지 이용하기  (0) 2024.07.09

직접 접속했을 때

 

// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    include: ['axios']
  },
  server: {
    proxy: {
      '/atemos': {
        target: 'http://127.0.0.1:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/atemos/, '/atemos')
      }
    }
  }
})

 

// src/api.ts
import axios from 'axios';

const API_URL = '/atemos';

export const fetchData = async (endpoint: string) => {
  console.log("API_URL:", API_URL); // API_URL이 올바른지 확인
  console.log("Endpoint:", endpoint); // endpoint가 올바른지 확인

  try {
  const response = await axios.get(`${API_URL}${endpoint}`); 
    console.log(response);
    return response.data;
  } catch (error) {
    console.error('Error fetching data:', error);
    throw error;
  }
};

 

import {fetchData} from '../../api' // Axios를 이용한 API 호출 함수 가져오기

interface Company {
  id: number;
  name: string;
}

const SignUp: React.FC = () => {
  const [selectedOption, setSelectedOption] = useState<Company[]>([]);
  const [isOptionSelected, setIsOptionSelected] = useState<boolean>(false);

  const changeTextColor = () => {
    setIsOptionSelected(true);
  };

  useEffect(() => {
    const getCompanyList = async () => {
      try {
        // API 호출 예시: /company/list 엔드포인트를 호출하여 데이터 가져오기
        const data = await fetchData('/company/list');
        setSelectedOption(data.companyList); // 데이터를 selectedOption에 설정
        console.log('selectedOption :', selectedOption);
    
      } catch (error) {
        console.error('Error while logging in:', error);
        // 에러 처리
      }
    };

    getCompanyList(); // 함수 호출

  }, []); // 빈 배열을 전달하여 컴포넌트가 마운트될 때 한 번만 호출하도록 설정
  // 만약 특정 상태나 프롭이 변경될 때마다 호출하려면 해당 상태나 프롭을 배열에 추가하면 됩니다.
function App() {
  const [isLoading, setIsLoading] = useState(true);
  const [data, dispatch] = useReducer(reducer, []);
  // 사용한 id 값 저장해놓기 
  const idRef = useRef(0);

  useEffect(() => {
    const storedData = localStorage.getItem("diary");
  
    if (!storedData) {
      setIsLoading(false);
      return;
    }
  
    // 문자열 형태의 데이터를 배열 형태로 파싱
    const parsedData = JSON.parse(storedData);

    console.log(parsedData);

  
    if (!Array.isArray(parsedData)) {
      setIsLoading(false);
      return;
    }
  
    let maxId = 0;

    console.log(parsedData)
  
    parsedData.forEach((item) => {
      if (Number(item.id) > maxId) {
        maxId = Number(item.id);
      }
    });
  
    console.log("가장 높은 수의 아이디:", maxId);
  
    idRef.current = maxId + 1;
  
    dispatch({
      type: "INIT",
      data: parsedData,
    });
    setIsLoading(false);
  }, []);

  if (isLoading) {return <div>데이터 로딩중입니다</div>}

  return (
    <>
    </>
  )
}

export default App

'Framework > React' 카테고리의 다른 글

[React] React-calendar => React-big-calendar  (0) 2024.08.30
[React] api 연결  (0) 2024.07.18
[React] 웹 스토리지 이용하기  (0) 2024.07.09
[React] navigate 에러 (홈 화면으로 돌아가기)  (0) 2024.07.08
[React] 폰트  (0) 2024.07.03

 

 

 

 

예시

test라는 키값에 hello 저장

 

사이트 별로 데이터 별도 보관

 

저장하기

문자열로 저장하기 때문에 오류남

 

JSON.stringify() -문자열로 변환

 

불러오기

불러오기 예시

 

객체처럼 생긴 문자열

 

진짜 객체

 

문자열 파싱해서 객체형태로 만들기

 

JSON.parse는 값이 undefined나 null이면 오류 발생함

 

삭제하기

방법 1

 

방법 2. 로컬스토리지에서 누르고 백스페이스

 

 

예시)

기존 코드

function reducer(state, action) {
  switch (action.type) {
    case "CREATE" : 
      return [action.data, ...state]
    case "UPDATE" : 
      return state.map((item) => String(item.id) === String(action.data.id) ? action.data : item )
    case "DELETE" :
      return state.filter((item) => String(item.id) !== String(action.id))
    default: return state;
  }
}

function App() {
  const [data, dispatch] = useReducer(reducer, []);
  // 사용한 id 값 저장해놓기 
  const idRef = useRef(0);

  // 새로운 일기 추가
  const onCreate = (createdDate, emotionId, content) => {
    dispatch({
      type: "CREATE",
      data: {
        id: idRef.current++,
        createdDate,
        emotionId,
        content
      }
    })
  }

  // 기존 일기 수정
  const onUpdate = (id, createdDate, emotionId, content) => {
    dispatch({
      type: "UPDATE",
      data: {
        id, createdDate, emotionId, content
      }
    })
  }

  // 기존 일기 삭제
  const onDelete = (id) => {
    dispatch({
      type: "DELETE",
      id
    })
  }

  return (
    <>
      <DiaryStateContext.Provider value={data}>
        <DiaryDispatchContext.Provider value={{onCreate, onUpdate, onDelete}}>
          <Routes>
            <Route path='/' element={<Home />} />
            <Route path='/new' element={<New />} />
            <Route path='/Diary/:id' element={<Diary />} />
            <Route path='/Edit/:id' element={<Edit />} />
            <Route path='*' element={<NotFound />} />
          </Routes>
        </DiaryDispatchContext.Provider>
      </DiaryStateContext.Provider>
    </>
  )
}

export default App

 

변경 코드

function reducer(state, action) {
  let nextState;

  switch (action.type) {
    case "INIT":
      return action.data;
    case "CREATE" : {
      nextState =  [action.data, ...state];
      break;
    }
    case "UPDATE" : {
      nextState = state.map((item) => String(item.id) === String(action.data.id) ? action.data : item );
      break;
    }
    case "DELETE" : {
      nextState = state.filter((item) => String(item.id) !== String(action.id));
      break;
    }
    default: return state;
  }

  localStorage.setItem("diary", JSON.stringify(nextState));
  return nextState;
}

function App() {
  const [data, dispatch] = useReducer(reducer, []);
  // 사용한 id 값 저장해놓기 
  const idRef = useRef(0);

  useEffect(() => {
    const storedData = localStorage.getItem("diary");
  
    if (!storedData) {
      return;
    }
  
    // 문자열 형태의 데이터를 배열 형태로 파싱
    const parsedData = JSON.parse(storedData);

    console.log(parsedData);

  
    if (!Array.isArray(parsedData)) {
      return;
    }
  
    let maxId = 0;

    console.log(parsedData)
  
    parsedData.forEach((item) => {
      if (Number(item.id) > maxId) {
        maxId = Number(item.id);
      }
    });
  
    console.log("가장 높은 수의 아이디:", maxId);
  
    idRef.current = maxId + 1;
  
    dispatch({
      type: "INIT",
      data: parsedData,
    });
  }, []);

  // 새로운 일기 추가
  const onCreate = (createdDate, emotionId, content) => {
    dispatch({
      type: "CREATE",
      data: {
        id: idRef.current++,
        createdDate,
        emotionId,
        content
      }
    })
  }

  // 기존 일기 수정
  const onUpdate = (id, createdDate, emotionId, content) => {
    dispatch({
      type: "UPDATE",
      data: {
        id: Number(id), createdDate, emotionId, content
      }
    })
  }

  // 기존 일기 삭제
  const onDelete = (id) => {
    dispatch({
      type: "DELETE",
      id
    })
  }

  return (
    <>
      <DiaryStateContext.Provider value={data}>
        <DiaryDispatchContext.Provider value={{onCreate, onUpdate, onDelete}}>
          <Routes>
            <Route path='/' element={<Home />} />
            <Route path='/new' element={<New />} />
            <Route path='/Diary/:id' element={<Diary />} />
            <Route path='/Edit/:id' element={<Edit />} />
            <Route path='*' element={<NotFound />} />
          </Routes>
        </DiaryDispatchContext.Provider>
      </DiaryStateContext.Provider>
    </>
  )
}

export default App

 

 

'Framework > React' 카테고리의 다른 글

[React] api 연결  (0) 2024.07.18
[React] 로딩 중  (0) 2024.07.09
[React] navigate 에러 (홈 화면으로 돌아가기)  (0) 2024.07.08
[React] 폰트  (0) 2024.07.03
[React] React Router  (0) 2024.07.03

컴포넌트들이 다 마운트 된 이후에만 navigate()함수가 작동할 수 있음

 

BrowserRouter가 공급하고 있는 기능임

 

변경 전

처음 실행되었을 때 부터 호출 시키면 안 됌 전부 마운트 시키고 나서 호출해야 함

    const getCurrentDiaryItem = () => {
        const currentDiaryItem = data.find((item) => String(item.id)===String(params.id))

        if(!currentDiaryItem) {window.alert("존재하지 않는 일기입니다."); nav("/", {replace: true})}

        return currentDiaryItem;
    }

    // 컴포넌트가 처음 호출 될 때 실행이 됌
    const currentDiaryItem = getCurrentDiaryItem();
    
    return <div>
        <Header />
        <Editor />
    </div>

 

 

변경 후

    useEffect(() => {
        const currentDiaryItem = data.find((item) => String(item.id)===String(params.id))

        if(!currentDiaryItem) {window.alert("존재하지 않는 일기입니다."); nav("/", {replace: true})}

        setCurrentDiaryItem(currentDiaryItem);
    }, [params.id, data]);
    
        return <div>
        <Header />
        <Editor initData={currentDiaryItem} />
    </div>

 

'Framework > React' 카테고리의 다른 글

[React] 로딩 중  (0) 2024.07.09
[React] 웹 스토리지 이용하기  (0) 2024.07.09
[React] 폰트  (0) 2024.07.03
[React] React Router  (0) 2024.07.03
[React] 페이지 라우팅  (0) 2024.07.02

+ Recent posts