본문 바로가기

TIL

[React] React에서 모달을 활용한 날짜와 시간 선택 관리하기

이번 프로젝트에서는 모달을 열어 사용자가 선택할 수 있는 날짜와 시간을 제공해야 했습니다. 이 과정에서 셀렉트 박스와 달력을 통해 사용자에게 직관적인 인터페이스를 제공하고, 선택된 값들을 함께 처리하는 기능을 구현했습니다.

 

 

import 부분

import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import dayjs from "dayjs"; // 날짜 처리 라이브러리
import "dayjs/locale/ko"; // 한국어 로케일
import { format } from "date-fns"
import { useMutation, useQueryClient } from "@tanstack/react-query"; 
import { timeHour, timeMinute } from "../data"; // 시간 관련 데이터
import useTimePicker from "../hooks/useTimePicker"; // 시간 선택 관련 훅

 

함수 호출 부분

// 폼 데이터 유효성 검사를 위한 Zod 스키마 정의
const formSchema = z.object({
  total: z.string().min(1), // total은 필수값
  data: z.string().min(1), // data도 필수값
});

function SyncModal() {
  // 한국어 로케일 설정
  dayjs.locale("ko");

  // React Hook Form 초기화
  const form = useForm({
    resolver: zodResolver(formSchema), // 유효성 검사 resolver 설정
    defaultValues: {
      total: "", // 기본값 설정
      data: "",
    },
  });

  // 날짜 및 시간 선택에 대한 상태 관리
  const [total, setTotal] = useState<string | undefined>(undefined); // 전체 시간 상태
  const [date, setDate] = useState<Date>(); // 선택한 날짜 상태
  const [time, setTime] = useState<Time>({
    hour: "00", // 시
    minute: "00", // 분
  });

  // 확인 및 취소 모달 관련 상태
  const [loading, setLoading] = useState(false); // 로딩 상태
  const [error, setError] = useState(""); // 에러 메시지 상태
  const [isCalendarOpen, setCalendarOpen] = useState(false); // 캘린더 열림 상태
  const [isTimeOpen, setIsTimeOpen] = useState(false); // 시간 선택 열림 상태

  // 캘린더에서 날짜 선택 함수
  const onSelect = (selectedDate: Date | undefined) => {
    setDate(selectedDate); // 선택된 날짜 설정
    setTotal(addTime(selectedDate, time)); // 선택된 날짜와 시간으로 total 업데이트
    setCalendarOpen(false); // 캘린더 닫기
  };

  // 시간 선택 관련 훅 사용
  const { onHourClickHandle, onMinuteClickHandle, convertToKST, addTime } =
    useTimePicker({ time, setTime, setTotal, date, isTimeOpen, setIsTimeOpen });

  // 화면에 표시될 데이터 포맷
  const totalInvaild = dayjs(total).format("YYYY. MM. DD / HH: mm");

  const queryClient = useQueryClient(); // 쿼리 클라이언트 초기화

  // 동기화 요청을 위한 mutation 정의
  const mutation = useMutation(() => postSyncList(total, data), {
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["sync"] }); // 쿼리 무효화
    },
    onError: () => {
      setError("서버와의 통신 중 문제가 발생했습니다."); // 에러 메시지 설정
    },
  });

  // 확인 버튼 클릭 시 수행되는 함수
  const onConfirmBtn = async () => {
    try {
      if (total === undefined) {
        setError("모두 필수값입니다."); // 필수값 체크
      } else if (total?.endsWith("00:00:00+09:00") === true) {
        setError("모두 필수값입니다."); // 잘못된 데이터 체크
      } else if (totalInvaild === "Invalid Date") {
        setError("모두 필수값입니다."); // 잘못된 날짜 체크
      } else {
        // mutation.mutate({ values: { total: total, data: data } });

        const kstTime = convertToKST(time.hour, time.minute); // 선택된 시간을 KST로 변환

        setLoading(true); // 로딩 시작
        setTimeout(async () => {
          setLoading(false); // 로딩 종료
        }, 3000);
        setTotal(kstTime); // 총 시간 설정
        toast({
          description:
            "동기화 요청을 보냈습니다. (api연결이 아직 되지 않았습니다.)", // 토스트 메시지
        });
        onClose(); // 모달 닫기

        // 상태 초기화
        setTotal("");
        setError("");
        setDate(undefined);
        setTime({
          hour: "00",
          minute: "00",
        });
      }
    } catch (error) {
      setError("서버와의 통신 중 문제가 발생했습니다."); // 에러 메시지 설정
    }
  };

  // 취소 버튼 클릭 시 수행되는 함수
  const onCancleBtn = () => {
    onClose(); // 모달 닫기
    // 상태 초기화
    setTotal("");
    setError("");
    setDate(undefined);
    setTime({
      hour: "00",
      minute: "00",
    });
  };

  // 제어 관련 훅 사용
  const { onClickControlButton } = useControl({
    setCalendarOpen,
    setIsTimeOpen,
  });

 

jsx 부분
  return (
    <div onClick={onClickControlButton}>
      <AlertDialog open={isModalOpen}>
        <AlertDialogContent className="md:w-[355px] h-[400px] bg-white rounded-lg shadow">
          <AlertDialogHeader>
            <AlertDialogTitle>동기화 예약</AlertDialogTitle>
            <AlertDialogDescription>
              언제 동기화하시겠습니까?
            </AlertDialogDescription>
          </AlertDialogHeader>

          <Form {...form}>
            <form onSubmit={(e) => e.preventDefault()}>
              {/* 날짜,시간 픽커 */}
              <p className="text-slate-600 text-xs font-normal mb-2">날짜</p>
              <div className="relative flex flex-col gap-3 ">
                <div>
                  <Button
                    variant="outline"
                    className={cn(
                      "w-[278px] justify-start text-left font-normal",
                      !date && "text-muted-foreground"
                    )}
                    onClick={(e) => {
                      e.stopPropagation();
                      setCalendarOpen(!isCalendarOpen); // 캘린더 열림/닫힘 토글
                      setIsTimeOpen(false); // 시간 선택 닫기
                    }}
                  >
                    <div className="w-full flex items-center justify-between">
                      {date ? (
                        format(date, "yyyy. MM. dd") // 선택된 날짜 포맷
                      ) : (
                        <span className="text-black">----. --. --</span>
                      )}
                      <CalendarIcon
                        id="calendar"
                        color="black"
                        className="mr-[2px] h-3.5 w-3.5"
                      />
                    </div>
                  </Button>
                </div>

                {isCalendarOpen && (
                  <div onClick={(e) => e.stopPropagation()}>
                    <div className="absolute top-[45px] bg-white shadow-lg border">
                      <Calendar
                        mode="single"
                        selected={date} // 선택된 날짜
                        onSelect={onSelect} // 날짜 선택 핸들러
                        initialFocus
                        disabledDays={[new Date()]} // 오늘 날짜 비활성화
                      />
                    </div>
                  </div>
                )}

                <p className="text-slate-600 text-xs font-normal mb-2">시간</p>
                <div>
                  <Button
                    variant="outline"
                    className={cn(
                      "w-[278px] justify-start text-left font-normal",
                      !time.hour && "text-muted-foreground"
                    )}
                    onClick={(e) => {
                      e.stopPropagation();
                      setIsTimeOpen(!isTimeOpen); // 시간 선택 열림/닫힘 토글
                      setCalendarOpen(false); // 캘린더 닫기
                    }}
                  >
                    <div className="w-full flex items-center justify-between">
                      {time.hour && time.minute ? (
                        `${time.hour}시 ${time.minute}분` // 선택된 시간 포맷
                      ) : (
                        <span className="text-black">00시 00분</span>
                      )}
                      <AlarmClock
                        id="clock"
                        color="black"
                        className="mr-[2px] h-3.5 w-3.5"
                      />
                    </div>
                  </Button>
                </div>
                {isTimeOpen && (
                  <div className="absolute top-[45px] w-[278px] bg-white border shadow-lg z-10">
                    <div className="flex justify-between">
                      <div className="flex flex-col border-r">
                        {timeHour.map((hour) => (
                          <Button
                            key={hour.value}
                            variant="outline"
                            className="h-8 text-xs font-normal w-full"
                            onClick={() => {
                              onHourClickHandle(hour.value); // 시 클릭 핸들러
                            }}
                          >
                            {hour.label} {/* 시 표시 */}
                          </Button>
                        ))}
                      </div>
                      <div className="flex flex-col">
                        {timeMinute.map((minute) => (
                          <Button
                            key={minute.value}
                            variant="outline"
                            className="h-8 text-xs font-normal w-full"
                            onClick={() => {
                              onMinuteClickHandle(minute.value); // 분 클릭 핸들러
                            }}
                          >
                            {minute.label} {/* 분 표시 */}
                          </Button>
                        ))}
                      </div>
                    </div>
                  </div>
                )}
              </div>

              {/* 필수 입력 항목 확인 및 오류 메시지 출력 */}
              {error && (
                <p className="text-red-500 text-xs mt-2">{error}</p>
              )}

              <div className="flex justify-between mt-2">
                <Button
                  variant="outline"
                  onClick={onCancleBtn} // 취소 버튼 클릭 핸들러
                >
                  취소
                </Button>
                <Button
                  variant="default"
                  onClick={onConfirmBtn} // 확인 버튼 클릭 핸들러
                >
                  {loading ? <Loader2 className="mr-2 animate-spin" /> : "확인"}
                </Button>
              </div>
            </form>
          </Form>
        </AlertDialogContent>
      </AlertDialog>
    </div>
  );
}

export default SyncModal;

 

숫자 설정 관련 설명

  • 시간과 분 설정: timeHour와 timeMinute는 각각 시간과 분을 나타내는 데이터입니다. 이 데이터는 사용자가 선택할 수 있는 시와 분의 목록을 제공합니다.
  • 시간 선택: 사용자가 시간 버튼을 클릭하면, onHourClickHandle이나 onMinuteClickHandle 함수가 호출되어 선택된 시간을 time 상태로 업데이트합니다.
  • 초기값 설정: 초기값으로 시와 분은 각각 "00"으로 설정되어 있으며, 이는 선택하지 않은 경우 기본적으로 표시됩니다.
  • 날짜 및 시간 입력 처리: 사용자가 날짜와 시간을 선택한 후, 이 값들이 올바른 형식으로 변환되어 total 상태에 저장됩니다. total은 최종적으로 동기화 요청에 사용되는 값입니다.

 

이를 위해 로딩 상태, 에러 메시지 상태, 캘린더 열림 상태, 시간 선택 열림 상태 등 여러 가지 상태를 분리하여 관리했습니다. 이러한 상태 관리 방식을 통해 각 요소의 동작을 명확히 하고, 사용자 경험을 개선할 수 있었습니다.

'TIL' 카테고리의 다른 글