최종 프로젝트 회고 #7 - 캘린더 페이지 내 약 등록 모달 (AddMediModal)

DO YEON KIM·2024년 10월 21일
0


오늘은 캘린더 페이지 내에 있는 약 등록 모달에 대해 작성해보고자 한다.

모바일 사이즈는 모달 창과 다르게 보여야해서 또 다른 페이지로 짰지만 이는 같은 기능이니 설명은 패스하겠다.

약 등록 모달을 통해 수파베이스에 복용 중인 약의 정보 및 알람을 등록할 수 있고, 전체 복용 중인 약에서 확인 가능하도록 하였다.

달력에 약을 작성 시 웹 사이즈 기준 왼쪽 사이드바에서 확인이 가능하도록 하였다. (사이드바에 관한건 추후 구체적으로 작성하겠다.)


폴더 구조


🟡src\components\templates\calendar\calendarModal\AddMediModal.tsx

import React, { useState, useEffect } from "react";
import Modal from "react-modal";
import axios from "axios";
import { useAuthStore } from "@/store/auth";

interface MediRecord {
  id: string;
  medi_name: string;
  medi_nickname: string;
  times: {
    morning: boolean;
    afternoon: boolean;
    evening: boolean;
  };
  notes: string;
  start_date: string;
  end_date: string;
  created_at: string;
  user_id: string;
  day_of_week: string[];
  notification_time: string[];
  repeat: boolean; 
}

interface AddMediModalProps {
  isOpen: boolean;
  onRequestClose: () => void;
  onAdd: (newMediRecord: MediRecord) => void;
  toast: {
    success: (message: string) => void;
    error: (message: string) => void;
  };
}
const AddMediModal: React.FC<AddMediModalProps> = ({
  isOpen,
  onRequestClose,
  onAdd,
  toast,
}) => {

  const { user } = useAuthStore();
  const [mediName, setMediName] = useState("");
  const [mediNickname, setMediNickname] = useState("");
  const [mediNames, setMediNames] = useState<string[]>([]);
  const [searchTerm, setSearchTerm] = useState("");
  const [times, setTimes] = useState({
    morning: false,
    afternoon: false,
    evening: false,
  });
  const [notes, setNotes] = useState("");
  const [startDate, setStartDate] = useState("");
  const [endDate, setEndDate] = useState("");
  const [dayOfWeek, setDayOfWeek] = useState<string[]>([]);
  const [notificationTime, setNotificationTime] = useState<string[]>([""]);
  const [notificationEnabled, setNotificationEnabled] = useState(false);

  useEffect(() => {
    const fetchMediNames = async () => {
      try {
        const response = await axios.get("/api/calendar/medi/names");
        setMediNames(
          response.data.map((item: { itemName: string }) => item.itemName)
        );
      } catch (error) {
        console.error("Failed to fetch medi names:", error);
      }
    };

    fetchMediNames();
  }, []);

  const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setTimes({ ...times, [e.target.name]: e.target.checked });
  };

  const validateForm = () => {
    if (!mediNickname.trim()) {
      toast.error("약 별명을 입력해주세요.");
      return false;
    }
    if (mediNickname.length > 6) {
      toast.error("약 별명은 최대 6자입니다.");
      return false;
    }
    if (!mediName) {
      toast.error("약 이름을 등록해주세요.");
      return false;
    }
    if (!startDate || !endDate) {
      toast.error("복용 기간을 선택해주세요.");
      return false;
    }
    if (notificationEnabled && notificationTime[0] === "") {
      toast.error("알림 시간을 설정해주세요.");
      return false;
    }
    return true;
  };

  const handleAdd = async () => {
    if (!user) return;
    if (!validateForm()) return;

    const newMediRecord: MediRecord = {
      id: crypto.randomUUID(),
      medi_name: mediName,
      medi_nickname: mediNickname,
      times: {
        morning: times.morning || false,
        afternoon: times.afternoon || false,
        evening: times.evening || false,
      },
      notes,
      start_date: startDate,
      end_date: endDate,
      created_at: new Date().toISOString(),
      user_id: user.id,
      day_of_week: notificationEnabled ? dayOfWeek : [],
      notification_time: notificationEnabled ? notificationTime : [],
      repeat: false,
    };

    try {
      console.log("Sending medication data:", newMediRecord);
      const response = await axios.post("/api/calendar/medi", newMediRecord);
      console.log("Server response:", response.data);

      if (response.status === 201) {
        console.log("Medication added successfully");
        onAdd(newMediRecord);

        // Clear form state
        setMediName("");
        setMediNickname("");
        setTimes({ morning: false, afternoon: false, evening: false });
        setNotes("");
        setStartDate("");
        setEndDate("");
        setDayOfWeek([]);
        setNotificationTime([""]);
        setNotificationEnabled(false);
        onRequestClose();
      }
      else {
        console.error("Failed to add record:", response.statusText);
        toast.error("약 등록에 실패했습니다.");
      }
    } catch (error) {
      console.error("Failed to add record:", error);
      toast.error("약 등록 중 오류가 발생했습니다.");
    }
  };

  const handleDayOfWeekChange = (day: string) => {
    setDayOfWeek((prevDays) =>
      prevDays.includes(day)
        ? prevDays.filter((d) => d !== day)
        : [...prevDays, day]
    );
  };

  const handleNotificationTimeChange = (index: number, value: string) => {
    const updatedNotificationTime = [...notificationTime];
    updatedNotificationTime[index] = value;
    setNotificationTime(updatedNotificationTime);
  };

  const handleAddNotificationTime = () => {
    setNotificationTime([...notificationTime, ""]);
  };

  const handleRemoveNotificationTime = (index: number) => {
    const updatedNotificationTime = notificationTime.filter((_, i) => i !== index);
    setNotificationTime(updatedNotificationTime);
  };

  return (
    <Modal
      isOpen={isOpen}
      onRequestClose={onRequestClose}
      contentLabel="Add Medication"
      className="fixed inset-0 flex items-center justify-center z-50"
      overlayClassName="fixed inset-0 bg-gray-900 bg-opacity-75 z-40"
    >
      <div className="bg-white rounded-lg p-8 max-w-md mx-auto z-50 relative">
        {/* 닫기 버튼 */}
        <button
          onClick={onRequestClose}
          className="absolute top-4 right-4 text-brand-gray-1000"
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            className="h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth={2}
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M6 18L18 6M6 6l12 12"
            />
          </svg>
        </button>

        <h2 className="text-2xl mb-4 text-brand-gray-800">나의 약</h2>

        {/* 약 별명 입력 */}
        <div className="mb-4">
          <input
            type="text"
            placeholder="약 별명(최대 6자)"
            value={mediNickname}
            onChange={(e) => setMediNickname(e.target.value)}
            className="border rounded w-full py-2 px-3 text-brand-gray-1000 leading-tight focus:outline-none"
          />
        </div>

        {/* 약 이름 입력 */}
        <div className="mb-4">
          <input
            list="mediNames"
            placeholder="약 이름을 검색하세요"
            value={mediName}
            onChange={(e) => {
              setMediName(e.target.value);
              setSearchTerm(e.target.value);
            }}
            className="border rounded w-full py-2 px-3 text-brand-gray-1000 leading-tight focus:outline-none"
          />
          <datalist id="mediNames">
            {mediNames
              .filter((name) =>
                name.toLowerCase().includes(searchTerm.toLowerCase())
              )
              .map((name, index) => (
                <option key={index} value={name} />
              ))}
          </datalist>
        </div>

        {/* 복용 시간 설정 */}
        <div className="mb-4">
          <label className="block text-brand-gray-600 text-sm font-bold mb-2">
            나의 약 등록
          </label>
          <div className="flex space-x-4 text-brand-gray-800 justify-between w-full">
            <button
              type="button"
              onClick={() => setTimes({ ...times, morning: !times.morning })}
              className={`px-4 py-2 rounded-full ${
                times.morning
                  ? "bg-brand-primary-500 text-white"
                  : "bg-brand-gray-50 text-brand-gray-800"
              } w-1/3`}
            >
              아침
            </button>
            <button
              type="button"
              onClick={() =>
                setTimes({ ...times, afternoon: !times.afternoon })
              }
              className={`px-4 py-2 rounded-full ${
                times.afternoon
                  ? "bg-brand-primary-500 text-white"
                  : "bg-brand-gray-50 text-brand-gray-800"
              } w-1/3`}
            >
              점심
            </button>
            <button
              type="button"
              onClick={() => setTimes({ ...times, evening: !times.evening })}
              className={`px-4 py-2 rounded-full ${
                times.evening
                  ? "bg-brand-primary-500 text-white"
                  : "bg-brand-gray-50 text-brand-gray-800"
              } w-1/3`}
            >
              저녁
            </button>
          </div>
        </div>

        {/* 복용 기간 설정 */}
        <div className="flex space-x-4 mb-4">
          <div className="w-1/2 flex items-center">
            <div className="flex items-center">
              <input
                type="date"
                value={startDate}
                onChange={(e) => setStartDate(e.target.value)}
                className="border rounded py-2 px-3 text-brand-gray-800 leading-tight focus:outline-none w-3/4" // 너비 조정
              />
              <span className="ml-3 text-brand-gray-800 ">부터</span>
            </div>
          </div>
          <div className="w-1/2 flex items-center">
            <div className="flex items-center">
              <input
                type="date"
                value={endDate}
                onChange={(e) => setEndDate(e.target.value)}
                className="border rounded py-2 px-3 text-brand-gray-800 leading-tight focus:outline-none w-3/4" // 너비 조정
              />
              <span className="ml-3 text-brand-gray-800 ">까지</span>
            </div>
          </div>
        </div>

        {/* 알림 설정 */}
        <div className="flex items-center justify-between mb-4">
          <div className="flex items-center">
            <span className="text-brand-gray-600">알림 설정</span>
            <div
              onClick={() => setNotificationEnabled(!notificationEnabled)}
              className={`relative w-12 h-6 flex items-center rounded-full ml-3 cursor-pointer ${
                notificationEnabled ? "bg-brand-primary-400" : "bg-brand-gray-400"
              }`}
            >
              <div
                className={`absolute w-6 h-6 bg-white rounded-full transition-transform transform ${
                  notificationEnabled ? "translate-x-6" : "translate-x-0"
                }`}
              ></div>
            </div>
          </div>
          {notificationEnabled && (
            <button
              onClick={handleAddNotificationTime}
              className="text-brand-primary-500 hover:text-brand-primary-700"
            >
              추가
            </button>
          )}
        </div>

        {notificationEnabled && (
          <div className="mb-4">
            <div className="flex flex-wrap space-x-2 mb-5">
              {["일", "월", "화", "수", "목", "금", "토"].map((day) => (
                <button
                  key={day}
                  type="button"
                  onClick={() => handleDayOfWeekChange(day)}
                  className={`px-4 py-2 rounded-full ${
                    dayOfWeek.includes(day)
                      ? "bg-brand-primary-500 text-white"
                      : "bg-brand-gray-50 text-brand-gray-800"
                  }`}
                >
                  {day}
                </button>
              ))}
            </div>

            {notificationTime.map((time, index) => (
              <div key={index} className="flex mb-2 items-center">
                <input
                  type="time"
                  value={time}
                  onChange={(e) =>
                    handleNotificationTimeChange(index, e.target.value)
                  }
                  className="border rounded w-full py-2 px-3 text-brand-gray-1000 leading-tight focus:outline-none"
                />
                <button
                  onClick={() => handleRemoveNotificationTime(index)}
                  className="ml-2 text-brand-gray-600 hover:text-brand-gray-800"
                >
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
                  </svg>
                </button>
              </div>
            ))}
          </div>
        )}

        {/* 메모 입력 */}
        <div className="mb-4">
          <label className="block text-brand-gray-600 text-sm font-bold mb-2">
            메모:
          </label>
          <textarea
            value={notes}
            onChange={(e) => setNotes(e.target.value)}
            placeholder="약에 대한 간단한 기록"
            className="border rounded w-full py-2 px-3 text-brand-gray-800 leading-tight focus:outline-none resize-none h-16"
          />
        </div>
        <div className="flex justify-center mt-4">
          <button
            type="button"
            onClick={handleAdd}
            className="px-6 py-2 bg-brand-primary-500 text-white rounded w-40"
          >
            저장
          </button>
        </div>
      </div>
    </Modal>
  );
};

export default AddMediModal;

interface AddMediModalProps {
  isOpen: boolean;
  onRequestClose: () => void;
  onAdd: (newMediRecord: MediRecord) => void;
  toast: {
    success: (message: string) => void;
    error: (message: string) => void;
  };
}

AddMediModal 컴포넌트가 받을 porps를 정의

() => void;
-> 매개변수를 받지 않고 아무 값도 반환하지 않음. 입력도 없고, 반환할 값도 없다는 것을 나타냄.

onAdd: (newMediRecord: MediRecord) => void;
-> 이 함수도 마찬가지.


const [mediName, setMediName] = useState("");
const [mediNickname, setMediNickname] = useState("");
const [mediNames, setMediNames] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [times, setTimes] = useState({
  morning: false,
  afternoon: false,
  evening: false,
});
const [notes, setNotes] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [dayOfWeek, setDayOfWeek] = useState<string[]>([]);
const [notificationTime, setNotificationTime] = useState<string[]>([""]);
const [notificationEnabled, setNotificationEnabled] = useState(false);

useState 정의.


useEffect(() => {
  const fetchMediNames = async () => {
    try {
      const response = await axios.get("/api/calendar/medi/names");
      setMediNames(response.data.map((item: { itemName: string }) => item.itemName));
    } catch (error) {
      console.error("Failed to fetch medi names:", error);
    }
  };

  fetchMediNames();
}, []);

useEffect로 데이터 불러오기

컴포넌트가 처음 렌더링 될 때 API에서 약 이름 목록을 받아옴.

받아온 약들을 medinames에 저장.


const validateForm = () => {
  if (!mediNickname.trim()) {
    toast.error("약 별명을 입력해주세요.");
    return false;
  }
  if (mediNickname.length > 6) {
    toast.error("약 별명은 최대 6자입니다.");
    return false;
  }
  if (!mediName) {
    toast.error("약 이름을 등록해주세요.");
    return false;
  }
  if (!startDate || !endDate) {
    toast.error("복용 기간을 선택해주세요.");
    return false;
  }
  if (notificationEnabled && notificationTime[0] === "") {
    toast.error("알림 시간을 설정해주세요.");
    return false;
  }
  return true;
};

후반에 추가했던 유효성 검사.


const handleAdd = async () => {
  if (!user) return;
  if (!validateForm()) return;

  const newMediRecord: MediRecord = {
    id: crypto.randomUUID(),
    medi_name: mediName,
    medi_nickname: mediNickname,
    times,
    notes,
    start_date: startDate,
    end_date: endDate,
    created_at: new Date().toISOString(),
    user_id: user.id,
    day_of_week: notificationEnabled ? dayOfWeek : [],
    notification_time: notificationEnabled ? notificationTime : [],
    repeat: false,
  };

  try {
    const response = await axios.post("/api/calendar/medi", newMediRecord);

    if (response.status === 201) {
      onAdd(newMediRecord);
      resetForm();
      onRequestClose();
    } else {
      toast.error("약 등록에 실패했습니다.");
    }
  } catch (error) {
    toast.error("약 등록 중 오류가 발생했습니다.");
  }
};

validateForm()으로 입력값 확인 후 유효한 데이터로 MediRecord 객체 생성.

API 요청을 통해 서버에 데이터를 전송해 준 뒤

성공시 상태 초기화 및 모달 닫기.


const handleDayOfWeekChange = (day: string) => {
  setDayOfWeek((prevDays) =>
    prevDays.includes(day)
      ? prevDays.filter((d) => d !== day)
      : [...prevDays, day]
  );
};

const handleAddNotificationTime = () => {
  setNotificationTime([...notificationTime, ""]);
};

const handleRemoveNotificationTime = (index: number) => {
  const updatedNotificationTime = notificationTime.filter((_, i) => i !== index);
  setNotificationTime(updatedNotificationTime);
};

요일 클릭시 선택 or 해제.

알림시간 추가 및 삭제 가능.

  • setDayOfWeek:

상태 업데이트 함수로, 선택된 요일들을 저장하는 배열인 dayOfWeek의 상태를 업데이트

  • includes(day):

prevDays 배열에 현재 선택된 day가 포함되어 있는지 확인

  • filter((d) => d !== day):

배열에서 특정 항목을 제거하는 메서드입니다. 만약 prevDays에 이미 해당 요일이 포함되어 있으면, 그것을 제


<Modal
  isOpen={isOpen}
  onRequestClose={onRequestClose}
  contentLabel="Add Medication"
  className="fixed inset-0 flex items-center justify-center z-50"
  overlayClassName="fixed inset-0 bg-gray-900 bg-opacity-75 z-40"
>
  {/* 모달 내용 */}
</Modal>

isopen에 따라 열고 닫히는 모달.

profile
프론트엔드 개발자를 향해서

0개의 댓글