본문 바로가기
🔥Next 뽀개기

리액트 캘린더와 Firebase로 CRD 구현하기

by 짱돌보리 2024. 12. 16.
728x90

📆리액트 캘린더와 Firebase로 CRD 구현하기

‼️구현하고자 하는 것

1. 사용자가 새로운 루틴을 추가할 수 있는 모달 창 제공
2. 사용자는 시작 날짜와 종료 날짜를 설정하고, 반복 주기 표시하기
3. 사용자가 특정 날짜에 루틴을 완료했다고 표시하기
4. react-calendar를 사용하여 날짜별로 루틴을 완료한 여부를 시각적으로 표시하기

🔥데이터를 Firestore에 저장할 때 사용할 데이터 구조 정의하기

  • habitName: 루틴 이름
  • startDate: 시작 날짜
  • endDate: 종료 날짜
  • frequency: 루틴을 수행할 요일들
type Habit = {
  id: string
  name: string
  startDate: string
  endDate: string
  frequency: string[]
  completedDates: string[]
}

🔥루틴 등록 모달 만들기

아래는 react-datepicker로 날짜 선택 후 각 필드를 firebase로 저장되는 로직이다.

type HabitModalProps = {
  onClose: () => void
  onAddHabit: () => void
}

type Habit = {
  name: string
  startDate: string
  endDate: string
  frequency: string[]
  completedDates: string[]
}

export default function AddHabitModal({
  onClose,
  onAddHabit,
}: HabitModalProps) {
  const [habitName, setHabitName] = useState('')
  const [startDate, setStartDate] = useState<Date | null>(null)
  const [endDate, setEndDate] = useState<Date | null>(null)
  const [frequency, setFrequency] = useState<string[]>([])

  const today = new Date()

  // 요일 선택
  const handleFrequencyChange = (e: ChangeEvent<HTMLInputElement>) => {
    const day = e.target.value
    if (e.target.checked) {
      setFrequency((prev) => [...prev, day])
    } else {
      setFrequency((prev) => prev.filter((item) => item !== day))
    }
  }

  const handleSubmit = async () => {
    const newHabit: Habit = {
      name: habitName,
      startDate: startDate?.toISOString().split('T')[0] || '',
      endDate: endDate?.toISOString().split('T')[0] || '',
      frequency,
      completedDates: [],
    }

    try {
      await addDoc(collection(db, 'habits'), newHabit)
      onAddHabit()
      alert('루틴 등록 완료!')
      onClose()
    } catch (e) {
      console.error('루틴 추가 실패: ', e)
      alert('오류가 발생했습니다. 다시 시도해주세요.')
    }
  }

  return (
    <div
      className="fixed inset-0 flex items-center justify-center bg-gray-500 bg-opacity-50"
      onClick={onClose}
    >
      <div
        className="mx-auto max-w-xl rounded-lg bg-white p-4 shadow-md"
        onClick={(e) => e.stopPropagation()}
      >
        <h2 className="mb-6 text-2xl font-semibold">🔥루틴 등록하기</h2>

        <div className="mb-4">
          <label className="mb-2 block text-sm">루틴 이름</label>
          <input
            type="text"
            value={habitName}
            onChange={(e) => setHabitName(e.target.value)}
            placeholder="등록할 루틴명을 입력하세요"
            className="w-full rounded-full border-2 border-gray-300 px-4 py-2 outline-none focus:border-green-40"
          />
        </div>

        <div className="mb-4">
          <label className="mb-2 flex items-center text-sm">시작 날짜</label>
          <DatePicker
            selected={startDate}
            onChange={(date) => setStartDate(date)}
            minDate={today} // 오늘 날짜 이후로 설정
            className="w-full cursor-pointer rounded-full border-2 border-gray-300 px-4 py-2 outline-none focus:border-green-40"
            dateFormat="yyyy-MM-dd"
            placeholderText="날짜를 선택해주세요"
          />
        </div>

        <div className="mb-4">
          <label className="mb-2 flex items-center text-sm">종료 날짜</label>
          <DatePicker
            selected={endDate}
            onChange={(date) => setEndDate(date)}
            minDate={startDate || today} // 시작 날짜 이후로 설정
            className="w-full cursor-pointer rounded-full border-2 border-gray-300 px-4 py-2 outline-none focus:border-green-40"
            dateFormat="yyyy-MM-dd"
            placeholderText="날짜를 선택해주세요"
          />
        </div>

        <div className="mb-4">
          <label className="mb-2 block text-sm">매주 수행할 요일</label>
          <div className="flex gap-4">
            {['월', '화', '수', '목', '금', '토', '일'].map((day) => (
              <label key={day} className="flex cursor-pointer items-center">
                <input
                  type="checkbox"
                  value={day}
                  checked={frequency.includes(day)}
                  onChange={handleFrequencyChange}
                  className="hidden"
                />
                <div
                  className={`flex h-10 w-10 items-center justify-center rounded-full border-2 ${
                    frequency.includes(day)
                      ? 'border-green-20 bg-green-20'
                      : 'border-gray-300'
                  }`}
                >
                  <span
                    className={`text-lg ${frequency.includes(day) ? 'text-white' : 'text-black'}`}
                  >
                    {day}
                  </span>
                </div>
              </label>
            ))}
          </div>
        </div>

        <button
          className="w-full rounded-full bg-green-30 px-6 py-3 text-white shadow-lg transition hover:bg-green-40"
          onClick={handleSubmit}
        >
          루틴 추가
        </button>
      </div>
    </div>
  )
}
  • 시작일은 오늘 이후로, 종료일은 시작일 이후로 선택할 수 있다.

초간단 모달 UI

  <div
      className="fixed inset-0 flex items-center justify-center bg-gray-500 bg-opacity-50"
      onClick={onClose}
    >
      <div
        className="mx-auto max-w-xl rounded-lg bg-white p-4 shadow-md"
        onClick={(e) => e.stopPropagation()}
      >
  • 모달이기 때문에 바깥 div에 onClose 함수를 넣어 모달이 닫히도록 처리하기
  • onClick={(e) => e.stopPropagation()}: 모달 내용Firebase 연동 및 데이터 가져오기 부분을 클릭했을 때, 배경을 클릭하지 않도록 막는 역할!

루틴 카드 띄우기

1. Firebase 연동 및 데이터 가져오기

getDocs() 함수를 사용하여 habits 컬렉션의 모든 데이터를 불러오고, setHabits()로 상태를 업데이트 해준다.

const fetchHabits = async () => {
  const habitsCollection = collection(db, 'habits')
  const habitSnapshot = await getDocs(habitsCollection)
  const habitList = habitSnapshot.docs.map((doc) => {
    const habitData = doc.data()
    return {
      id: doc.id,
      name: habitData.name,
      startDate: habitData.startDate,
      endDate: habitData.endDate,
      frequency: habitData.frequency,
      completedDates: habitData.completedDates || [],
    }
  })
  setHabits(habitList)
}

2. 날짜 클릭 및 완료 처리

  • 시작날짜와 종료날짜 기간 내에 있는 요일들만 날짜 클릭할 수 있도록 하기
 const handleDateClick = async (habitId: string, date: Date) => {
    const dateString = date.toISOString().split('T')[0] // YYYY-MM-DD 형식
    const habit = habits.find((h) => h.id === habitId)

    if (habit && !habit.completedDates.includes(dateString)) {
      const confirmCompletion = window.confirm(
        `${habit.name}을(를) 완료하시겠습니까?`,
      )
      if (confirmCompletion) {
        const habitRef = doc(db, 'habits', habitId)
        await updateDoc(habitRef, {
          completedDates: arrayUnion(dateString),
        })
        fetchHabits()
      }
    }
  }

  const isDateClickable = (habit: Habit, date: Date) => {
  const dayOfWeek = date.getDay()
  const frequencyDays = habit.frequency.map((day) =>
    ['일', '월', '화', '수', '목', '금', '토'].indexOf(day),
  )

  return (
    frequencyDays.includes(dayOfWeek) &&
    date >= new Date(habit.startDate) &&
    date <= new Date(habit.endDate)
  )
}

const isDateCompleted = (habit: Habit, date: Date) => {
  const dateString = date.toISOString().split('T')[0]
  return habit.completedDates.includes(dateString)
}

const isDateMissed = (habit: Habit, date: Date) => {
  const today = new Date()
  return date < today && isDateClickable(habit, date)
}

3. 캘린더에서 날짜 클릭 처리

날짜를 클릭했을 때 isDateClickable, isDateCompleted, isDateMissed 함수로 날짜 상태를 체크하고, 맞는 상태에 따른 타일에 css 적용하기

  • isDateClickable: 날짜가 클릭 가능한 날짜인지 확인
  • isDateCompleted: 날짜가 이미 완료된 날짜인지 확인
  • isDateMissed: 날짜가 완료되지 않은 미션 놓친 날짜인지 확인
<Calendar
  tileClassName={({ date }) => {
    if (isDateCompleted(habit, date)) {
      return 'completed'
    } else if (isDateMissed(habit, date)) {
      return 'missed'
    } else if (isDateClickable(habit, date)) {
      return 'clickable'
    } else {
      return 'not-clickable'
    }
  }}
  onClickDay={(date) => {
    if (isDateClickable(habit, date) && !isDateMissed(habit, date)) {
      handleDateClick(habit.id, date)
    }
  }}
/>

tailwind 적용이 안 되기 때문에 따로 css 파일을 넣어 import 해주었다...!

.react-calendar {
  width: 100%;
  max-width: 415px;
  margin: auto;
  border-radius: 1rem;
  border: #f3f4f6 1px solid;
  padding: 1rem;
  font-family: 'AppleSDGothicNeoB';
}

.react-calendar__tile {
  width: 3rem;
  height: 3rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  transition: background-color 0.3s ease;
  margin: 0;
  box-sizing: border-box;
}

.clickable {
  background-color: #f3f4f6 !important;
  border: #1e4a19 2px solid !important;
  color: black;
  cursor: pointer;
}

.not-clickable {
  background-color: #f0f0f0 !important;
  color: gray;
  cursor: not-allowed;
}

.completed {
  background-color: #1e4a19 !important;
  color: white;
}

.missed {
  background-color: #f44336 !important;
  color: white;
  text-decoration: line-through;
}

루틴 삭제하기

react-icons라이브러리를 사용해 아이콘 가져왔음..!

  const handleDeleteHabit = async (habitId: string) => {
    const confirmDelete = window.confirm('이 루틴을 삭제하시겠습니까?')
    if (confirmDelete) {
      const habitRef = doc(db, 'habits', habitId)
      await deleteDoc(habitRef)
      fetchHabits()
    }
  }  

<button
  onClick={() => handleDeleteHabit(habit.id)}
  className="text-red-500"
  aria-label="루틴 삭제 버튼"
  >
    <FiTrash size={20} />
</button>