React

[React] 근태(출퇴근) 관리 달력 만들기 (with. date-fns)

캐럿노트 2024. 11. 27. 16:41

완성 화면 미리보기

최종 완성 화면

프로젝트 생성

vite로 프로젝트를 생성했다.

react : v18.13.1
styled-components : v6.1.13
npm create vite@latest
cd calendar
npm install

 

 

npm i styled-components

 

date-fns 라이브러리 설치

date-fns는 JavaScript에서 날짜와 시간 변환등을 다루는데 매우 유용한 도구이다.

date-fns : v4.1.0
npm i date-fns

 

Calendar 컴포넌트 만들기

generateCalendar.ts

특정 월의 달력을 생성하는 함수

// src/generateCalendar.ts

import {
  startOfMonth,
  endOfMonth,
  startOfWeek,
  endOfWeek,
  addDays,
} from "date-fns";

const generateCalendar = (date: Date) => {
  // 1. 해당 월의 첫 날과 마지막 날
  const startDate = startOfWeek(startOfMonth(date), { weekStartsOn: 0 }); // 주의 시작: 일요일
  const endDate = endOfWeek(endOfMonth(date), { weekStartsOn: 0 }); // 주의 끝: 일요일

  const calendar = [];
  let currentDate = startDate;

  // 2. 주 단위로 반복하여 날짜 추가
  while (currentDate <= endDate) {
    const week = Array.from({ length: 7 }, (_, i) => addDays(currentDate, i));
    calendar.push(week);
    currentDate = addDays(currentDate, 7); // 다음 주로 이동
  }

  return calendar; // 2D 배열 (주 단위로 구성된 달력)
};

export default generateCalendar;

 

Calendar.tsx

달력 컴포넌트

// src/Calendar.tsx

import styled from "styled-components";
import { format, isSameMonth, isToday } from "date-fns";
import generateCalendar from "./generateCalendar";

interface ICalendar {
  currentDate: Date;
  setCurrentDate: (date: Date) => void;
  handleDateClick: (value: Date) => void;
}

/**
 * Calendar
 *
 * 월별 날짜를 표시하고, 이전/다음 달로 이동할 수 있는 캘린더 컴포넌트.
 * 날짜 클릭 이벤트를 처리할 수 있습니다.
 *
 * @component
 * @param {Object} props - 컴포넌트의 props
 * @param {Date} props.currentDate - 현재 캘린더가 표시할 날짜 (월의 첫 날로 설정)
 * @param {Function} props.setCurrentDate - 현재 날짜를 설정하는 함수
 * @param {Function} props.handleDateClick - 특정 날짜를 클릭했을 때 호출되는 함수
 */
const Calendar = ({
  currentDate,
  setCurrentDate,
  handleDateClick,
}: ICalendar) => {
  const calendar = generateCalendar(currentDate);

  // 이전 달로 이동
  const handlePrevMonth = () => {
    const newDate = new Date(
      currentDate.getFullYear(),
      currentDate.getMonth() - 1,
      1
    );
    setCurrentDate(newDate);
  };

  // 다음 달로 이동
  const handleNextMonth = () => {
    const newDate = new Date(
      currentDate.getFullYear(),
      currentDate.getMonth() + 1,
      1
    );
    setCurrentDate(newDate);
  };

  return (
    <CalendarWrapper>
      {/* 월 네비게이션 */}
      <Header>
        <Button onClick={handlePrevMonth}>이전</Button>
        <MonthLabel>{format(currentDate, "yyyy년 MM월")}</MonthLabel>
        <Button onClick={handleNextMonth}>다음</Button>
      </Header>

      {/* 요일 헤더 */}
      <Weekdays>
        {["일", "월", "화", "수", "목", "금", "토"].map((day) => (
          <Weekday key={day}>{day}</Weekday>
        ))}
      </Weekdays>

      {/* 날짜 렌더링 */}
      <Days>
        {calendar.map((week, i) => (
          <Week key={i}>
            {week.map((day) => (
              <Day
                key={`${week}-${day}`}
                $isSameMonth={isSameMonth(day, currentDate)}
                $isToday={isToday(day)}
                onClick={() => handleDateClick(day)} // 날짜 클릭 이벤트
              >
                {format(day, "d")}
              </Day>
            ))}
          </Week>
        ))}
      </Days>
    </CalendarWrapper>
  );
};

export default Calendar;

const CalendarWrapper = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: 400px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  background-color: #ffffff;
`;

const Header = styled.div`
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  margin-bottom: 16px;
`;

const MonthLabel = styled.h2`
  font-size: 18px;
  font-weight: bold;
  margin: 0;
`;

const Button = styled.button`
  background: none;
  border: none;
  color: #333;
  font-size: 14px;
  cursor: pointer;
  &:hover {
    color: #007bff;
  }
`;

const Weekdays = styled.div`
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 8px;
  width: 100%;
  margin-bottom: 8px;
`;

const Weekday = styled.div`
  text-align: center;
  font-size: 14px;
  font-weight: bold;
  color: #666;
`;

const Days = styled.div`
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 8px;
  width: 100%;
`;

const Week = styled.div`
  display: contents;
`;

const Day = styled.div<{ $isToday: boolean; $isSameMonth: boolean }>`
  text-align: center;
  padding: 8px;
  border-radius: 4px;
  background-color: ${({ $isToday }) => ($isToday ? "#f0f8ff" : "transparent")};
  color: ${({ $isSameMonth }) => ($isSameMonth ? "#000000" : "#cccccc")};
  font-weight: ${({ $isToday }) => ($isToday ? "bold" : "normal")};
  cursor: pointer;

  &:hover {
    background-color: #e0e0e0;
  }
`;

 

App.tsx

선택한 날짜와 어느달을 보여줘야하는지 저장하는 State는 App.tsx에서 관리한다.

// src/App.tsx

import { useState } from "react";
import Calendar from "./Calendar";
import styled from "styled-components";

const App = () => {
  const [selectedDate, setSelectedDate] = useState<Date>(new Date());
  const [currentDate, setCurrentDate] = useState<Date>(new Date()); // 캘린더의 현재 월

  // 날짜 클릭 시 처리할 로직
  const handleDateClick = (date: Date) => {
    setSelectedDate(date);
  };

  // 오늘 날짜로 돌아가기
  const handleResetDate = () => {
    const today = new Date();
    setSelectedDate(today); // 선택된 날짜 업데이트
    setCurrentDate(today); // 캘린더의 현재 월도 오늘로 설정
  };

  return (
    <Layout>
      <BtnReset onClick={handleResetDate}>오늘</BtnReset>
      <Calendar
        currentDate={currentDate}
        setCurrentDate={setCurrentDate}
        handleDateClick={handleDateClick}
      />
      {<p>선택된 날짜: {selectedDate ? selectedDate.toDateString() : null}</p>}
    </Layout>
  );
};

export default App;

const Layout = styled.div`
  width: 100vw;
  height: 100vh;
  justify-content: center;
  display: flex;
  align-items: center;
  flex-direction: column;
`;

const BtnReset = styled.button``;

 

중간 완성 미리보기

  • 위 코드까지 적용하면 일반적인 달력 형태가 나오게 된다.
  • 이전달, 다음달 등을 클릭시 월 단위로 끊어서 달력에 표시되며 클릭시 선택한 날짜를 state에 저장한다.
  • "오늘" 클릭시 현재 날짜의 달력으로 이동하며 state도 오늘 기준으로 변경된다.

중간 결과 화면

 

최종 근태 화면 만들기

위 중간 완성물에서 출근, 퇴근 시간이 표시될 영역을 추가해야한다.

 

샘플 데이터 만들기

샘플 양식은 대충 만들었는데 실무에서는 백엔드와 소통하여 데이터 양식과 타입을 맞추면 될 것 같다.

// src/attendance.ts

export const attendanceData: {
  [key: string]: { checkIn: Date; checkOut: Date };
} = {
  "2024-10-21": {
    checkIn: new Date("2024-10-21T08:52"),
    checkOut: new Date("2024-10-21T18:26"),
  },
  "2024-10-29": {
    checkIn: new Date("2024-10-29T08:49"),
    checkOut: new Date("2024-10-29T18:11"),
  },
  "2024-11-15": {
    checkIn: new Date("2024-11-15T08:40"),
    checkOut: new Date("2024-11-15T18:17"),
  },
  "2024-11-18": {
    checkIn: new Date("2024-11-18T08:52"),
    checkOut: new Date("2024-11-18T18:13"),
  },
  "2024-11-19": {
    checkIn: new Date("2024-11-19T08:57"),
    checkOut: new Date("2024-11-19T18:03"),
  },
  "2024-11-21": {
    checkIn: new Date("2024-11-21T08:43"),
    checkOut: new Date("2024-11-21T18:11"),
  },
  "2024-11-27": {
    checkIn: new Date("2024-11-27T08:51"),
    checkOut: new Date("2024-11-27T18:09"),
  },
};

 

Calendar.tsx 파일 업그레이드

// src/ Calendar.tsx

import styled from "styled-components";
import { format, isSameMonth, isToday } from "date-fns";
import generateCalendar from "./generateCalendar";
import { attendanceData } from "./attendance";

interface ICalendar {
  currentDate: Date;
  setCurrentDate: (date: Date) => void;
  handleDateClick: (value: Date) => void;
}

/**
 * Calendar
 *
 * 월별 날짜를 표시하고, 이전/다음 달로 이동할 수 있는 캘린더 컴포넌트.
 * 날짜 클릭 이벤트를 처리할 수 있습니다.
 *
 * @component
 * @param {Object} props - 컴포넌트의 props
 * @param {Date} props.currentDate - 현재 캘린더가 표시할 날짜 (월의 첫 날로 설정)
 * @param {Function} props.setCurrentDate - 현재 날짜를 설정하는 함수
 * @param {Function} props.handleDateClick - 특정 날짜를 클릭했을 때 호출되는 함수
 */
const Calendar = ({
  currentDate,
  setCurrentDate,
  handleDateClick,
}: ICalendar) => {
  const calendar = generateCalendar(currentDate);

  // 이전 달로 이동
  const handlePrevMonth = () => {
    const newDate = new Date(
      currentDate.getFullYear(),
      currentDate.getMonth() - 1,
      1
    );
    setCurrentDate(newDate);
  };

  // 다음 달로 이동
  const handleNextMonth = () => {
    const newDate = new Date(
      currentDate.getFullYear(),
      currentDate.getMonth() + 1,
      1
    );
    setCurrentDate(newDate);
  };

  const attendanceText = (day: Date) => {
    const attendanceObj = attendanceData[format(day, "yyyy-MM-dd")];
    return (
      <>
        {attendanceObj?.checkIn && (
          <Time>{`출근: ${format(attendanceObj.checkIn, "HH:mm")}`}</Time>
        )}
        {attendanceObj?.checkOut && (
          <Time>{`퇴근: ${format(attendanceObj.checkOut, "HH:mm")}`}</Time>
        )}
      </>
    );
  };

  return (
    <CalendarWrapper>
      {/* 월 네비게이션 */}
      <Header>
        <Button onClick={handlePrevMonth}>이전</Button>
        <MonthLabel>{format(currentDate, "yyyy년 MM월")}</MonthLabel>
        <Button onClick={handleNextMonth}>다음</Button>
      </Header>

      {/* 요일 헤더 */}
      <Weekdays>
        {["일", "월", "화", "수", "목", "금", "토"].map((day) => (
          <Weekday key={day}>{day}</Weekday>
        ))}
      </Weekdays>

      {/* 날짜 렌더링 */}
      <Days>
        {calendar.map((week, i) => (
          <Week key={i}>
            {week.map((day) => (
              <Day
                key={`${week}-${day}`}
                $isSameMonth={isSameMonth(day, currentDate)}
                $isToday={isToday(day)}
                onClick={() => handleDateClick(day)} // 날짜 클릭 이벤트
              >
                {format(day, "d")}
                {attendanceData[format(day, "yyyy-MM-dd")]
                  ? attendanceText(day)
                  : null}
              </Day>
            ))}
          </Week>
        ))}
      </Days>
    </CalendarWrapper>
  );
};

export default Calendar;

const CalendarWrapper = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  background-color: #ffffff;
`;

const Header = styled.div`
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  margin-bottom: 16px;
`;

const MonthLabel = styled.h2`
  font-size: 18px;
  font-weight: bold;
  margin: 0;
`;

const Button = styled.button`
  background: none;
  border: none;
  color: #333;
  font-size: 14px;
  cursor: pointer;
  &:hover {
    color: #007bff;
  }
`;

const Weekdays = styled.div`
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 8px;
  width: 100%;
  margin-bottom: 8px;
`;

const Weekday = styled.div`
  text-align: center;
  font-size: 14px;
  font-weight: bold;
  color: #666;
`;

const Days = styled.div`
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  width: 100%;
`;

const Week = styled.div`
  display: contents;
`;

const Day = styled.div<{ $isToday: boolean; $isSameMonth: boolean }>`
  display: flex;
  flex-direction: column;
  text-align: center;
  gap: 5px;
  width: 80px;
  height: 100px;
  border: 1px solid #e0e0e0;
  background-color: ${({ $isToday }) => ($isToday ? "#f0f8ff" : "transparent")};
  color: ${({ $isSameMonth }) => ($isSameMonth ? "#000000" : "#cccccc")};
  font-weight: ${({ $isToday }) => ($isToday ? "bold" : "normal")};
  cursor: pointer;

  &:hover {
    background-color: #e0e0e0;
  }
`;

const Time = styled.span`
  font-size: 10px;
  font-weight: 400;
`;

 

완성 결과

위 코드를 반영하면 처음과 같이 최종 완성 화면을 확인할 수 있다.