diff --git a/src/components/ScheduleItemList/ScheduleItem/ScheduleItem.styles.js b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleItem/ScheduleItem.styles.js
similarity index 100%
rename from src/components/ScheduleItemList/ScheduleItem/ScheduleItem.styles.js
rename to src/components/Common/SchedulePage/ScheduleItemList/ScheduleItem/ScheduleItem.styles.js
diff --git a/src/components/Common/SchedulePage/ScheduleItemList/ScheduleItemList.jsx b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleItemList.jsx
new file mode 100644
index 000000000..e2420e5b7
--- /dev/null
+++ b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleItemList.jsx
@@ -0,0 +1,260 @@
+import React, { useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+
+import ScheduleItem from "@/components/Common/SchedulePage/ScheduleItemList/ScheduleItem/ScheduleItem";
+import ScheduleVoteItem from "@/components/Common/SchedulePage/ScheduleItemList/ScheduleVoteItem/ScheduleVoteItem";
+import { SCHEDULE_PAGE_TYPE } from "@/constants/calendarConstants";
+import { DottedCalendarIcon, ScheduleAddIcon } from "@/constants/iconConstants";
+import { UI_TYPE } from "@/constants/uiConstants";
+import { resetOverlappedSchedules } from "@/features/schedule/schedule-slice";
+import {
+ openScheduleCreateModal,
+ openScheduleProposalModal,
+} from "@/features/ui/ui-slice";
+
+import {
+ TodoHeader,
+ TodoBody,
+ TodoButton,
+ TodoList,
+ TodoH2,
+ TodoH3,
+ TodoBodyHeader,
+ ScheduleItemListLayoutAside,
+ TodoBodyHeaderButton,
+ TodoTabButton,
+} from "./ScheduleItemList.styles";
+
+const ScheduleItemList = () => {
+ const dispatch = useDispatch();
+ const {
+ scheduleProposals,
+ todaySchedules,
+ schedulesForTheWeek,
+ overlappedScheduleInfo: {
+ title: overlappedScheduleTitle,
+ schedules: overlappedSchedules,
+ },
+ currentPageType,
+ } = useSelector((state) => state.schedule);
+ const [currentTabIndex, setCurrentTabIndex] = useState(0);
+
+ const isPersonal = currentPageType === SCHEDULE_PAGE_TYPE.PERSONAL;
+
+ const isTodayTab =
+ (isPersonal && currentTabIndex === 0) ||
+ (!isPersonal && currentTabIndex === 1);
+
+ const isForTheWeekTab =
+ (isPersonal && currentTabIndex === 1) ||
+ (!isPersonal && currentTabIndex === 2);
+
+ const isProposalTab = !isPersonal && currentTabIndex === 0;
+
+ const isOverlappedSchedulesOn = overlappedSchedules.length > 0;
+
+ const handleMenuOpen = () => {
+ dispatch(
+ openScheduleCreateModal({
+ type: UI_TYPE.PERSONAL_SCHEDULE,
+ }),
+ );
+ };
+
+ if (isOverlappedSchedulesOn) {
+ return (
+
+
+
+
+ {overlappedScheduleTitle.split("\n").map((line) => (
+ {line}
+ ))}
+ 동안의 일정들
+
+ dispatch(resetOverlappedSchedules())}
+ >
+ 돌아가기
+
+
+
+ {overlappedSchedules.map((schedule) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ if (isPersonal) {
+ return (
+
+
+ setCurrentTabIndex(0)}
+ >
+ 오늘 일정
+
+ setCurrentTabIndex(1)}
+ >
+ 예정
+
+
+
+
+
+ {isTodayTab ? "오늘 일정" : "예정"}
+
+ {isTodayTab
+ ? "하루동안의 할 일을 관리합니다."
+ : "앞으로 7일간 예정된 일정을 확인합니다."}
+
+
+
+
+ 일정 추가
+
+
+ {(isTodayTab && todaySchedules.length === 0) ||
+ (isForTheWeekTab && schedulesForTheWeek.length === 0) ? (
+
+ 아직 추가된 일정이 없습니다!
할 일을 추가하여 하루동안 할
+ 일을 관리해보세요.
+
+ ) : (
+
+ {(isTodayTab ? todaySchedules : schedulesForTheWeek).map(
+ (schedule) => {
+ return (
+
+ );
+ },
+ )}
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+ setCurrentTabIndex(0)}
+ >
+ 일정 후보
+
+ setCurrentTabIndex(1)}
+ >
+ 오늘 일정
+
+ setCurrentTabIndex(2)}
+ >
+ 예정
+
+
+
+
+
+
+ {/* eslint-disable-next-line no-nested-ternary */}
+ {isProposalTab
+ ? "일정 후보(최대 5개)"
+ : isTodayTab
+ ? "오늘 일정"
+ : "예정"}
+
+
+ {/* eslint-disable-next-line no-nested-ternary */}
+ {isProposalTab
+ ? "함꼐 일정을 조율합니다."
+ : isTodayTab
+ ? "하루동안의 할 일을 관리합니다."
+ : "앞으로 7일간 예정된 일정을 확인합니다."}
+
+
+
+ {isProposalTab && (
+ {}}>
+
+ 후보 선택
+
+ )}
+ dispatch(openScheduleProposalModal())}
+ >
+
+ 후보 추가
+
+
+
+ {/* eslint-disable-next-line no-nested-ternary */}
+ {isProposalTab && scheduleProposals.length === 0 ? (
+ {}}>
+ 공유한 사용자들에게 일정 후보를
+
+ 먼저 제안해보세요!
+
+ ) : (isTodayTab && todaySchedules.length === 0) ||
+ (isForTheWeekTab && schedulesForTheWeek.length === 0) ? (
+ {}}>
+ 아직 추가된 일정이 없습니다!
할 일을 추가하여 하루동안 할 일을
+ 관리해보세요.
+
+ ) : (
+
+ {/* eslint-disable-next-line no-nested-ternary */}
+ {(isProposalTab
+ ? scheduleProposals
+ : isTodayTab
+ ? todaySchedules
+ : schedulesForTheWeek
+ ).map((schedule) => {
+ if (isProposalTab)
+ return (
+
+ );
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ );
+};
+
+export default ScheduleItemList;
diff --git a/src/components/ScheduleItemList/ScheduleItemList.styles.js b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleItemList.styles.js
similarity index 97%
rename from src/components/ScheduleItemList/ScheduleItemList.styles.js
rename to src/components/Common/SchedulePage/ScheduleItemList/ScheduleItemList.styles.js
index 18fff9ba1..8ed197344 100644
--- a/src/components/ScheduleItemList/ScheduleItemList.styles.js
+++ b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleItemList.styles.js
@@ -88,6 +88,11 @@ export const TodoBodyHeader = styled.header`
& > div {
min-height: 43px;
}
+
+ & > .buttons {
+ display: flex;
+ gap: 14px;
+ }
`;
export const TodoH2 = styled.h2`
@@ -121,10 +126,11 @@ export const TodoH3 = styled.h3`
`;
export const TodoBodyHeaderButton = styled.button`
+ width: 47px;
display: flex;
flex-direction: column;
align-items: center;
- justify-content: center;
+ justify-content: space-between;
font-size: ${({
theme: {
typography: { size },
diff --git a/src/components/Common/SchedulePage/ScheduleItemList/ScheduleVoteItem/ScheduleVoteItem.jsx b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleVoteItem/ScheduleVoteItem.jsx
new file mode 100644
index 000000000..a49466288
--- /dev/null
+++ b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleVoteItem/ScheduleVoteItem.jsx
@@ -0,0 +1,7 @@
+import React from "react";
+
+const ScheduleVoteItem = () => {
+ return
ScheduleVoteItem
;
+};
+
+export default ScheduleVoteItem;
diff --git a/src/components/Common/SchedulePage/ScheduleItemList/ScheduleVoteItem/ScheduleVoteItem.styles.js b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleVoteItem/ScheduleVoteItem.styles.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/pages/PersonalSchedulePage/PersonalSchedulePage.styles.js b/src/components/Common/SchedulePage/SchedulePageLayout.styles.js
similarity index 100%
rename from src/pages/PersonalSchedulePage/PersonalSchedulePage.styles.js
rename to src/components/Common/SchedulePage/SchedulePageLayout.styles.js
diff --git a/src/components/Common/ScheduleProposalModal/DurationPicker/DurationPicker.jsx b/src/components/Common/ScheduleProposalModal/DurationPicker/DurationPicker.jsx
new file mode 100644
index 000000000..ed29f2318
--- /dev/null
+++ b/src/components/Common/ScheduleProposalModal/DurationPicker/DurationPicker.jsx
@@ -0,0 +1,86 @@
+import React, { useRef, useState } from "react";
+import { toast } from "react-toastify";
+
+import useOutsideClick from "@/hooks/useOutsideClick";
+
+import { RelativeDiv } from "./DurationPicker.styles";
+
+const getHours = (minutes) => Math.floor(minutes / 60);
+
+const convertMinutesToDurationString = (minutes) => {
+ if (minutes < 60) return `${minutes}분`;
+ if (minutes % 60 === 0) return `${minutes / 60}시간`;
+ return `${getHours(minutes)}시간 ${minutes % 60}분`;
+};
+
+const DurationPicker = ({ value, onChange }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [hours, setHours] = useState(getHours(value));
+ const [minutes, setMinutes] = useState(value % 60);
+
+ const pickerRef = useRef();
+
+ useOutsideClick(pickerRef, () => {
+ setMinutes(value % 60);
+ setHours(getHours(value));
+ setIsOpen(false);
+ });
+
+ const handleSubmit = () => {
+ if (!minutes && !hours) {
+ toast.error("일정 최소 구간은 1분 이상이어야 합니다.");
+ } else {
+ setIsOpen(false);
+ onChange(minutes + hours * 60);
+ }
+ };
+ return (
+
+
+ {isOpen && (
+
+
+
+ {Array.from({ length: 25 }, (_, idx) => idx).map((el) => (
+
+ ))}
+
+
+ {Array.from({ length: 60 }, (_, idx) => idx).map((el) => (
+
+ ))}
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default DurationPicker;
diff --git a/src/components/Common/ScheduleProposalModal/DurationPicker/DurationPicker.styles.js b/src/components/Common/ScheduleProposalModal/DurationPicker/DurationPicker.styles.js
new file mode 100644
index 000000000..5cc1d6bbe
--- /dev/null
+++ b/src/components/Common/ScheduleProposalModal/DurationPicker/DurationPicker.styles.js
@@ -0,0 +1,104 @@
+import styled from "styled-components";
+
+export const RelativeDiv = styled.div`
+ position: relative;
+
+ & > button {
+ width: 250px;
+ height: 33px;
+ background: ${({ theme: { colors } }) => colors.bg_01};
+ font-size: ${({
+ theme: {
+ typography: { size },
+ },
+ }) => size.s2};
+ font-weight: ${({
+ theme: {
+ typography: { weight },
+ },
+ }) => weight.medium};
+ text-align: center;
+ cursor: pointer;
+ font-family: inherit;
+ }
+
+ & > div {
+ position: absolute;
+ height: 262px;
+ z-index: 101;
+ bottom: calc(100% + 7px);
+ background: ${({ theme: { colors } }) => colors.white};
+ position: absolute;
+ width: 198px;
+ height: 262px;
+ padding: 7px 16px;
+ box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
+ -webkit-box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
+ -moz-box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);
+ background-color: ${({ theme: { colors } }) => colors.white};
+
+ & > div:first-child {
+ margin-bottom: 20px;
+ display: flex;
+ gap: 8px;
+ height: 199px;
+
+ & > div {
+ flex: 1;
+ width: 50px;
+ height: 100%;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8.5px;
+ overflow-y: scroll;
+ -ms-overflow-style: none; /* Internet Explorer 10+ */
+ scrollbar-width: none; /* Firefox */
+
+ &::-webkit-scrollbar {
+ display: none; /* Safari and Chrome */
+ }
+
+ & > button {
+ width: 100%;
+ height: 33px;
+ min-height: 33px;
+ }
+ }
+ }
+
+ & > div:last-child {
+ width: 100%;
+ height: 28px;
+ display: flex;
+ justify-content: flex-end;
+
+ & > button {
+ width: 73px;
+ height: 28px;
+ }
+ }
+
+ & button {
+ cursor: pointer;
+ text-align: center;
+ border-radius: 5px;
+ font-size: ${({
+ theme: {
+ typography: { size },
+ },
+ }) => size.s2};
+
+ &.selected,
+ &.confirm {
+ background-color: ${({ theme: { colors } }) => colors.primary};
+ color: ${({ theme: { colors } }) => colors.white};
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ color: ${({ theme: { colors } }) => colors.disabled_text};
+ }
+ }
+ }
+`;
diff --git a/src/components/Common/ScheduleProposalModal/EditedProposalForm.jsx b/src/components/Common/ScheduleProposalModal/EditedProposalForm.jsx
new file mode 100644
index 000000000..97e19a73e
--- /dev/null
+++ b/src/components/Common/ScheduleProposalModal/EditedProposalForm.jsx
@@ -0,0 +1,380 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { toast } from "react-toastify";
+
+import _ from "lodash";
+import moment from "moment";
+
+import { BackArrowIcon } from "@/constants/iconConstants";
+import { changeRecommendedProposal } from "@/features/schedule/schedule-slice";
+import {
+ calculateIsAllDay,
+ calculateMinUntilDateString,
+ getInitializeEndTimeAfterChangeStartTime,
+ setByweekday,
+ validateByweekday,
+ validateDateTimeIsValid,
+ validateInterval,
+ validateUntil,
+} from "@/utils/calendarUtils";
+
+import DateAndTime from "../ScheduleModal/DateAndTime";
+import Repeat from "../ScheduleModal/Repeat/Repeat";
+import RepeatDetail from "../ScheduleModal/RepeatDetail/RepeatDetail";
+import {
+ AllDayCheckBoxDiv,
+ FooterDiv,
+ RepeatContainerDiv,
+} from "../ScheduleModal/ScheduleModal.styles";
+import { SubmitButton } from "../ScheduleModal.Shared.styles";
+
+const initialFormValues = {
+ startDate: moment().format("YYYY-MM-DD"),
+ startTime: moment().format("HH:mm"),
+ endDate: moment().format("YYYY-MM-DD"),
+ endTime: moment().format("HH:mm"),
+ isAllDay: false,
+ freq: "NONE",
+ interval: "",
+ byweekday: [],
+ until: "",
+};
+
+const EditedProposalForm = ({ index, onClose }) => {
+ const dispatch = useDispatch();
+ const recommendedScheduleProposals = useSelector(
+ ({ schedule }) => schedule.recommendedScheduleProposals,
+ );
+ const prevFormValue = useRef(
+ recommendedScheduleProposals[index] || initialFormValues,
+ );
+
+ const [formValues, setFormValues] = useState(
+ recommendedScheduleProposals[index] || initialFormValues,
+ );
+
+ useEffect(() => {
+ prevFormValue.current =
+ recommendedScheduleProposals[index] || initialFormValues;
+ setFormValues(recommendedScheduleProposals[index] || initialFormValues);
+ }, [recommendedScheduleProposals, index]);
+
+ // handle date change
+ const handleDateValueChange = (date, id) => {
+ const value = moment(date).format("YYYY-MM-DD");
+
+ if (id === "startDate") {
+ setFormValues((prev) => {
+ const endDate =
+ !prev.endDate || prev.endDate < value ? value : prev.endDate;
+ const startDateWeekNum = new Date(value).getDay();
+ const byweekday =
+ prev.freq.startsWith("WEEKLY") &&
+ prev.byweekday.indexOf(startDateWeekNum) === -1
+ ? [startDateWeekNum]
+ : prev.byweekday;
+ return {
+ ...prev,
+ startDate: value,
+ endDate,
+ byweekday,
+ until: calculateMinUntilDateString(
+ value,
+ prev.freq,
+ prev.interval,
+ prev.until === "",
+ ),
+ isAllDay: calculateIsAllDay(
+ value,
+ prev.startTime,
+ endDate,
+ prev.endTime,
+ ),
+ };
+ });
+ } else if (id === "endDate") {
+ setFormValues((prev) => {
+ const startDate =
+ !prev.startDate || prev.startDate > value ? value : prev.startDate;
+ const startDateWeekNum = new Date(startDate).getDay();
+ const byweekday =
+ prev.freq.startsWith("WEEKLY") &&
+ prev.byweekday.indexOf(startDateWeekNum) === -1
+ ? [startDateWeekNum]
+ : prev.byweekday;
+ return {
+ ...prev,
+ startDate,
+ byweekday,
+ endDate: value,
+ isAllDay: calculateIsAllDay(
+ startDate,
+ prev.startTime,
+ value,
+ prev.endTime,
+ ),
+ };
+ });
+ }
+ };
+ // handle time change
+ const handleTimeValueChange = (value, id) => {
+ if (id === "startTime") {
+ setFormValues((prev) => ({
+ ...prev,
+ startTime: value,
+ endTime: getInitializeEndTimeAfterChangeStartTime(
+ prev.startDate,
+ prev.endDate,
+ value,
+ prev.endTime,
+ ),
+ isAllDay: calculateIsAllDay(
+ prev.startDate,
+ value,
+ prev.endDate,
+ prev.endTime,
+ ),
+ }));
+ } else if (id === "endTime") {
+ setFormValues((prev) => ({
+ ...prev,
+ endTime: value,
+ isAllDay: calculateIsAllDay(
+ prev.startDate,
+ prev.startTime,
+ prev.endDate,
+ value,
+ ),
+ }));
+ }
+ };
+ // handle isAllDay change
+ const handleIsAllDayValueChange = (event) => {
+ const { checked } = event.target;
+ setFormValues((prev) => ({
+ ...prev,
+ isAllDay: checked,
+ endDate: checked ? prev.startDate : prev.endDate,
+ startTime: checked ? "00:00" : prev.startTime,
+ endTime: checked ? "23:59" : prev.endTime,
+ }));
+ };
+ // handle freq change
+ const handleFreqValueChange = (event) => {
+ const {
+ target: { value },
+ } = event;
+ setFormValues((prev) => ({
+ ...prev,
+ freq: value,
+ interval: value !== "NONE" ? 1 : "",
+ until: calculateMinUntilDateString(
+ prev.startDate,
+ value,
+ 1,
+ Boolean(!prev.until),
+ ),
+ byweekday: value.startsWith("WEEKLY")
+ ? [new Date(prev.startDate).getDay()]
+ : [],
+ }));
+ };
+ // handle interval change
+ const handleIntervalValueChange = (event) => {
+ const {
+ target: { value },
+ } = event;
+
+ if (Number.isNaN(Number(value))) return;
+
+ setFormValues((prev) => ({
+ ...prev,
+ interval: Number(value) >= 0 ? value : 1,
+ until: calculateMinUntilDateString(
+ prev.startDate,
+ prev.freq,
+ value,
+ Boolean(!prev.until),
+ ),
+ }));
+ };
+ // handle byweekday change
+ const handleByweekdayValueChange = ({ target: { checked } }, weekNum) => {
+ setFormValues((prev) => ({
+ ...prev,
+ byweekday:
+ new Date(prev.startDate).getDay() === weekNum
+ ? prev.byweekday
+ : setByweekday(weekNum, prev.byweekday, checked),
+ }));
+ };
+ const toggleUntilOrNot = (event) => {
+ const {
+ target: { value },
+ } = event;
+ setFormValues((prev) => ({
+ ...prev,
+ until: calculateMinUntilDateString(
+ prev.startDate,
+ prev.freq,
+ prev.interval,
+ value === "NO",
+ ),
+ }));
+ };
+ // handle until change
+ const handleUntilValueChange = (date) => {
+ const value = moment(date).format("YYYY-MM-DD");
+ setFormValues((prev) => ({
+ ...prev,
+ until: value,
+ }));
+ };
+
+ const checkIsEmpty = () => {
+ return _.isEqual(formValues, prevFormValue.current);
+ };
+ // valdate when change event occurs
+ const checkFormIsFilledOrChanged = () => {
+ if (checkIsEmpty()) {
+ return false;
+ }
+
+ return (
+ formValues.startDate !== "" &&
+ formValues.startTime !== "" &&
+ formValues.endDate !== "" &&
+ formValues.endTime !== "" &&
+ (formValues.freq === "NONE" || formValues.interval > 0) &&
+ (formValues.freq === "WEEKLY" ? formValues.byweekday.length > 0 : true)
+ );
+ };
+
+ const isUniqueProposalForm = () => {
+ const doesProposalAlreadyExist = recommendedScheduleProposals.some(
+ (proposal) => {
+ const copiedProposal = { ...proposal, byweekday: undefined };
+ const copiedFormValues = { ...formValues, byweekday: undefined };
+ const byweekday1 = [...proposal.byweekday];
+ const byweekday2 = [...formValues.byweekday];
+ byweekday1.sort();
+ byweekday2.sort();
+ return (
+ _.isEqual(copiedProposal, copiedFormValues) &&
+ _.isEqual(byweekday1, byweekday2)
+ );
+ },
+ );
+ if (doesProposalAlreadyExist) {
+ toast.error("이미 동일한 일정 후보가 존재합니다.");
+ return false;
+ }
+
+ return true;
+ };
+
+ const handleSubmit = () => {
+ // form 유효성 검사
+ if (
+ !validateDateTimeIsValid(
+ formValues.startDate,
+ formValues.startTime,
+ formValues.endDate,
+ formValues.endTime,
+ ) ||
+ !validateInterval(formValues) ||
+ !validateByweekday(formValues) ||
+ !validateUntil(formValues) ||
+ !isUniqueProposalForm()
+ ) {
+ return;
+ }
+
+ // 일정 저장 로직
+ dispatch(changeRecommendedProposal({ formValues, index }));
+
+ // 폼 초기화
+ setFormValues(initialFormValues);
+
+ // 메뉴 닫기
+ onClose();
+ };
+
+ const handleCancelClick = () => {
+ onClose();
+ // reset
+ prevFormValue.current = initialFormValues;
+ setFormValues(initialFormValues);
+ };
+
+ return (
+
+
+
+ {formValues.startDate && (
+
+
+
+ )}
+
+
+
+
+
+
+ 저장하기
+
+
+
+
+
+ );
+};
+
+export default EditedProposalForm;
diff --git a/src/components/Common/ScheduleProposalModal/ScheduleProposalModal.jsx b/src/components/Common/ScheduleProposalModal/ScheduleProposalModal.jsx
new file mode 100644
index 000000000..75eae8da6
--- /dev/null
+++ b/src/components/Common/ScheduleProposalModal/ScheduleProposalModal.jsx
@@ -0,0 +1,309 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { toast } from "react-toastify";
+
+import _ from "lodash";
+import moment from "moment";
+
+import { FooterDiv } from "@/components/Common/ScheduleModal/ScheduleModal.styles";
+import EditedProposalForm from "@/components/Common/ScheduleProposalModal/EditedProposalForm";
+import {
+ enrollScheudleProposals,
+ getScheduleProposals,
+} from "@/features/schedule/schedule-service";
+import { resetRecommendedScheduleProposals } from "@/features/schedule/schedule-slice";
+import {
+ getInitializeEndTimeAfterChangeStartTime,
+ getTimeString,
+ validateDateTimeIsValid,
+} from "@/utils/calendarUtils";
+import convertToUTC from "@/utils/convertToUTC";
+
+import DurationPicker from "./DurationPicker/DurationPicker";
+import {
+ ProposalParamsWrapperDiv,
+ RecommendedProposalsDiv,
+ SliderWrapperDiv,
+} from "./ScheduleProposalModal.styles";
+import FormModal from "../Modal/FormModal/FormModal";
+import DateAndTime from "../ScheduleModal/DateAndTime";
+import {
+ DetailTextarea,
+ LabelH4,
+ ScheduleModalLayoutDiv,
+ SubmitButton,
+ TitleInput,
+} from "../ScheduleModal.Shared.styles";
+
+const initialFormValues = {
+ title: "",
+ content: "",
+ selectedRecommendationIndexes: [],
+};
+
+const initialProposalParams = {
+ startDateStr: moment().format("YYYY-MM-DD"),
+ startTimeStr: moment().format("HH:mm"),
+ endDateStr: moment().format("YYYY-MM-DD"),
+ endTimeStr: moment().format("HH:mm"),
+ minDuration: 60, // 분
+};
+
+const ScheduleProposalModal = () => {
+ const prevFormValue = useRef(initialFormValues);
+ const dispatch = useDispatch();
+ const { recommendedScheduleProposals, isLoading } = useSelector(
+ ({ schedule }) => schedule,
+ );
+
+ const [formValues, setFormValues] = useState(initialFormValues);
+ const [prevProposalParams, setPrevProposalParams] = useState(
+ initialProposalParams,
+ );
+ const [proposalParams, setProposalParams] = useState(initialProposalParams);
+
+ const [editiedProposalIndex, setEditiedProposalIndex] = useState(null); // 수정하는 건 뭔가, 새로 만들기의 경우 어떻게 해야 할까? null, -1, 그 외 인덱스
+
+ const isSlideOnEditForm =
+ editiedProposalIndex !== -1 && editiedProposalIndex !== null;
+
+ const handleDateValueChange = (date, id) => {
+ const value = moment(date).format("YYYY-MM-DD");
+
+ if (id === "startDate") {
+ setProposalParams((prev) => ({ ...prev, startDateStr: value }));
+ } else if (id === "endDate") {
+ setProposalParams((prev) => ({
+ ...prev,
+ startDateStr: prev.startDateStr > value ? value : prev.startDateStr,
+ endDateStr: value,
+ }));
+ }
+ };
+
+ const handleTimeValueChange = (value, id) => {
+ if (id === "startTime") {
+ setProposalParams((prev) => ({
+ ...prev,
+ startTimeStr: value,
+ endTimeStr: getInitializeEndTimeAfterChangeStartTime(
+ prev.startDateStr,
+ prev.endDateStr,
+ value,
+ prev.endTimeStr,
+ ),
+ }));
+ } else if (id === "endTime") {
+ setProposalParams((prev) => ({
+ ...prev,
+ endTimeStr: value,
+ }));
+ }
+ };
+
+ const handleGettingProposal = async () => {
+ if (
+ validateDateTimeIsValid(
+ proposalParams.startDateStr,
+ proposalParams.startTimeStr,
+ proposalParams.endDateStr,
+ proposalParams.endTimeStr,
+ ) &&
+ !_.isEqual(prevProposalParams, proposalParams)
+ ) {
+ try {
+ await dispatch(getScheduleProposals(proposalParams)).unwrap();
+ setPrevProposalParams(proposalParams);
+ } catch (error) {
+ toast.error("일정 추천 중 오류가 발생했습니다.");
+ }
+ }
+ };
+
+ const checkIsEmpty = () => {
+ const trimmedFormValues = {
+ ...formValues,
+ title: formValues.title.trim(),
+ content: formValues.content.trim(),
+ };
+ return (
+ _.isEqual(trimmedFormValues, prevFormValue.current) &&
+ recommendedScheduleProposals.length === 0
+ );
+ };
+
+ const handleSelectRecommendation = (index) => {
+ setFormValues((prev) => {
+ const isAlreadySelected =
+ prev.selectedRecommendationIndexes.indexOf(index) !== -1;
+
+ return {
+ ...prev,
+ selectedRecommendationIndexes: isAlreadySelected
+ ? prev.selectedRecommendationIndexes.filter((el) => el !== index)
+ : [...prev.selectedRecommendationIndexes, index],
+ };
+ });
+ };
+
+ const checkFormValuesAreAllFilled = () =>
+ formValues.title.trim().length > 0 &&
+ formValues.content.trim().length > 0 &&
+ formValues.selectedRecommendationIndexes.length > 0;
+
+ const handleProposalSubmit = () => {
+ if (!checkFormValuesAreAllFilled()) {
+ toast.error("등록에 필요한 모든 값을 입력해주세요");
+ } else {
+ dispatch(enrollScheudleProposals({ ...formValues }));
+ }
+ };
+
+ useEffect(() => {
+ return () => {
+ dispatch(resetRecommendedScheduleProposals());
+ };
+ }, []);
+
+ return (
+
+
+ {isSlideOnEditForm ? "일정 후보 수정" : "일정 후보 등록"}
+
+ setFormValues((prev) =>
+ isSlideOnEditForm ? prev : { ...prev, title: e.target.value },
+ )
+ }
+ value={formValues.title}
+ placeholder="일정 후보 제목"
+ disabled={isSlideOnEditForm}
+ />
+
+ setFormValues((prev) =>
+ isSlideOnEditForm ? prev : { ...prev, content: e.target.value },
+ )
+ }
+ value={formValues.content}
+ placeholder="상세 내용"
+ disabled={isSlideOnEditForm}
+ />
+
+
+
+
+
+ 일정 최소 구간
+
+
+ setProposalParams((prev) => ({
+ ...prev,
+ minDuration: value,
+ }))
+ }
+ />
+
+
+
+
+ {recommendedScheduleProposals.map((proposal, index) => (
+
+
+
+
+
+ {getTimeString(
+ convertToUTC(
+ proposal.startDate,
+ proposal.startTime,
+ ),
+ convertToUTC(proposal.endDate, proposal.endTime),
+ proposal.isAllDay,
+ )}
+
+
+ 반복
+
+
+
+
+
+ ))}
+
+
+
+
+ 등록하기
+
+
+
+
setEditiedProposalIndex(-1)}
+ />
+
+
+
+
+ );
+};
+
+export default ScheduleProposalModal;
diff --git a/src/components/Common/ScheduleProposalModal/ScheduleProposalModal.styles.js b/src/components/Common/ScheduleProposalModal/ScheduleProposalModal.styles.js
new file mode 100644
index 000000000..a17ed20c6
--- /dev/null
+++ b/src/components/Common/ScheduleProposalModal/ScheduleProposalModal.styles.js
@@ -0,0 +1,183 @@
+import styled from "styled-components";
+
+const MODAL_INLINE_MARGIN = 20;
+
+export const SliderWrapperDiv = styled.div`
+ & > .slider {
+ display: flex;
+ gap: ${MODAL_INLINE_MARGIN * 2}px;
+ &.toLeft {
+ animation: slideToLeft 0.5s ease-out forwards;
+ }
+
+ &.toRight {
+ animation: slideToRight 0.5s ease-out forwards;
+ }
+
+ @keyframes slideToLeft {
+ from {
+ transform: translateX(calc(-100% - ${MODAL_INLINE_MARGIN * 2}px));
+ }
+ to {
+ transform: translateX(0);
+ }
+ }
+ @keyframes slideToRight {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(calc(-100% - ${MODAL_INLINE_MARGIN * 2}px));
+ }
+ }
+
+ & > div {
+ width: 100%;
+ min-width: 550px;
+ }
+ }
+`;
+
+export const ProposalParamsWrapperDiv = styled.div`
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 18px;
+
+ & > .durationAndSubmit {
+ display: flex;
+ justify-content: space-between;
+ & > button {
+ width: 132px;
+ height: 33px;
+ border-radius: 5px;
+ background-color: ${({ theme: { colors } }) => colors.primary_light};
+ text-align: center;
+ font-size: ${({
+ theme: {
+ typography: { size },
+ },
+ }) => size.s2};
+ color: ${({ theme: { colors } }) => colors.white};
+ cursor: pointer;
+
+ &:disabled {
+ background-color: ${({ theme: { colors } }) => colors.btn_02};
+ }
+ }
+ }
+`;
+
+export const RecommendedProposalsDiv = styled.div`
+ padding-inline: 14px;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ & > div {
+ margin-bottom: 6px;
+ &:last-child {
+ margin-bottom: 12px;
+ }
+ width: 100%;
+ height: 35px;
+ border: 1px solid ${({ theme: { colors } }) => colors.primary};
+ padding-left: 20px;
+ padding-right: 10px;
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ & > .edit {
+ height: 17px;
+ min-width: 55px;
+ border-radius: 5px;
+ background-color: ${({ theme: { colors } }) => colors.btn_01};
+ color: ${({ theme: { colors } }) => colors.white};
+ text-align: center;
+ line-height: 17px;
+ font-size: 10px;
+ font-weight: ${({
+ theme: {
+ typography: { weight },
+ },
+ }) => weight.medium};
+ cursor: pointer;
+ }
+
+ & > div {
+ display: flex;
+ gap: 26px;
+
+ & > button {
+ cursor: pointer;
+ border: 1px solid ${({ theme: { colors } }) => colors.disabled_text};
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ & > div {
+ width: 12px;
+ height: 12px;
+ background-color: ${({ theme: { colors } }) => colors.text_01};
+ border-radius: 50%;
+ }
+ }
+
+ & > div {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ & > span:first-child {
+ line-height: 20px;
+ font-size: ${({
+ theme: {
+ typography: { size },
+ },
+ }) => size.s2};
+ font-weight: ${({
+ theme: {
+ typography: { weight },
+ },
+ }) => weight.medium};
+ }
+
+ & > span.freqIndicator {
+ min-width: 38px;
+ height: 17px;
+ background-color: ${({ theme: { colors } }) => colors.btn_02};
+ border-radius: 10px;
+ color: ${({ theme: { colors } }) => colors.white};
+ text-align: center;
+ font-size: 10px;
+ line-height: 17px;
+
+ &.active {
+ background-color: ${({ theme: { colors } }) => colors.primary};
+ }
+ }
+ }
+ }
+ }
+
+ & > button {
+ margin-bottom: 12px;
+ width: 100%;
+ max-width: 311px;
+ height: 35px;
+ border-radius: 5px;
+ background-color: ${({ theme: { colors } }) => colors.btn_02};
+ text-align: center;
+ font-size: ${({
+ theme: {
+ typography: { size },
+ },
+ }) => size.s2};
+ cursor: pointer;
+ }
+`;
diff --git a/src/components/ScheduleItemList/ScheduleItemList.jsx b/src/components/ScheduleItemList/ScheduleItemList.jsx
deleted file mode 100644
index 8eb22ae78..000000000
--- a/src/components/ScheduleItemList/ScheduleItemList.jsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import React, { useState, useEffect } from "react";
-import { useDispatch, useSelector } from "react-redux";
-
-import ScheduleItem from "@/components/ScheduleItemList/ScheduleItem/ScheduleItem";
-import { ScheduleAddIcon } from "@/constants/iconConstants";
-import { UI_TYPE } from "@/constants/uiConstants";
-import {
- getSchedulesForTheWeek,
- getTodaySchedules,
-} from "@/features/schedule/schedule-service.js";
-import { resetOverlappedSchedules } from "@/features/schedule/schedule-slice";
-import { openScheduleCreateModal } from "@/features/ui/ui-slice";
-
-import {
- TodoHeader,
- TodoBody,
- TodoButton,
- TodoList,
- TodoH2,
- TodoH3,
- TodoBodyHeader,
- ScheduleItemListLayoutAside,
- TodoBodyHeaderButton,
- TodoTabButton,
-} from "./ScheduleItemList.styles";
-
-const ScheduleItemList = () => {
- const dispatch = useDispatch();
- const {
- todaySchedules,
- schedulesForTheWeek,
- overlappedScheduleInfo: {
- title: overlappedScheduleTitle,
- schedules: overlappedSchedules,
- },
- } = useSelector((state) => state.schedule);
- const [isTodayTab, setIsTodayTab] = useState(true);
-
- const isOverlappedSchedulesOn = overlappedSchedules.length > 0;
- useEffect(() => {
- dispatch(getTodaySchedules());
- dispatch(getSchedulesForTheWeek());
- }, []);
-
- const handleMenuOpen = () => {
- dispatch(
- openScheduleCreateModal({
- type: UI_TYPE.PERSONAL_SCHEDULE,
- }),
- );
- };
-
- if (isOverlappedSchedulesOn) {
- return (
-
-
-
-
- {overlappedScheduleTitle.split("\n").map((line) => (
- {line}
- ))}
- 동안의 일정들
-
- dispatch(resetOverlappedSchedules())}
- >
- 돌아가기
-
-
-
- {overlappedSchedules.map((schedule) => (
-
- ))}
-
-
-
- );
- }
-
- return (
-
-
- setIsTodayTab(true)}
- >
- 오늘 일정
-
- setIsTodayTab(false)}
- >
- 예정
-
-
-
-
-
- {isTodayTab ? "오늘 일정" : "예정"}
-
- {isTodayTab
- ? "하루동안의 할 일을 관리합니다."
- : "앞으로 7일간 예정된 일정을 확인합니다."}
-
-
-
-
- 일정 추가
-
-
- {(isTodayTab && todaySchedules.length === 0) ||
- (!isTodayTab && schedulesForTheWeek.length === 0) ? (
-
- 아직 추가된 일정이 없습니다!
할 일을 추가하여 하루동안 할 일을
- 관리해보세요.
-
- ) : (
-
- {(isTodayTab ? todaySchedules : schedulesForTheWeek).map(
- (schedule) => {
- return (
-
- );
- },
- )}
-
- )}
-
-
- );
-};
-
-export default ScheduleItemList;
diff --git a/src/components/SharePage/InviteUser.jsx b/src/components/SharePage/InviteUser.jsx
deleted file mode 100644
index 7cd5ce356..000000000
--- a/src/components/SharePage/InviteUser.jsx
+++ /dev/null
@@ -1,130 +0,0 @@
-// import { useDispatch, useSelector } from "react-redux";
-
-// import {
-// Box,
-// Button,
-// TextField,
-// Autocomplete,
-// Menu,
-// List,
-// ListItem,
-// ListItemText,
-// InputAdornment,
-// } from "@mui/material";
-
-// import { selectGroup } from "@/features/group/group-slice.js";
-
-const InviteUser = () => {
- // const groupList = useSelector((state) => state.group);
- // const dispatch = useDispatch();
-
- // const selectGroupHandler = (e, value) => {
- // setSelectedGroup(value);
- // dispatch(selectGroup(value));
- // };
-
- return (
-
- test
- {/*
-
- option.name}
- style={{ width: 150, marginLeft: "1rem" }}
- onChange={selectGroupHandler}
- renderInput={(params) => }
- />
-
-
*/}
-
- );
-};
-
-export default InviteUser;
diff --git a/src/components/SharePage/ShareTodoList/ShareTodoList.jsx b/src/components/SharePage/ShareTodoList/ShareTodoList.jsx
deleted file mode 100644
index 2ddc05619..000000000
--- a/src/components/SharePage/ShareTodoList/ShareTodoList.jsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import React, { useState } from "react";
-import { useDispatch, useSelector } from "react-redux";
-
-import ScheduleModal from "@/components/Common/ScheduleModal/ScheduleModal.jsx";
-import ScheduleItem from "@/components/ScheduleItemList/ScheduleItem/ScheduleItem.jsx";
-import { UI_TYPE } from "@/constants/uiConstants.js";
-import { openScheduleCreateModal } from "@/features/ui/ui-slice.js";
-
-import {
- TodoContainer,
- TodoHeader,
- TodoTabs,
- TodoTab,
- AddEventButton,
- TodoBody,
- TodoTitle,
- TodoSubtitle,
- TodoList,
- Wrapper,
-} from "./ShareTodoList.styles.js";
-
-const ShareTodoList = () => {
- const dispatch = useDispatch();
- const { openedModal } = useSelector((state) => state.ui);
- const { todaySchedules } = useSelector((state) => state.schedule);
-
- const [selectedTab, setSelectedTab] = useState(true);
-
- const createInviteCodeHandler = () => {};
-
- return (
-
-
-
-
- setSelectedTab(true)}
- >
- 일정 후보
-
- setSelectedTab(false)}
- >
- 오늘 할 일
-
- {/* ... */}
-
-
-
- {selectedTab ? (
- <>
-
- 일정 후보{" "}
-
- dispatch(
- openScheduleCreateModal({ type: UI_TYPE.SHARE_SCHEDULE }),
- )
- }
- >
-
- 일정 후보 추가
-
-
-
- 이번 달 일정을 등록하여 사람들에게 미리 알려주세요!
-
-
- >
- ) : (
- <>
-
- 오늘의 할 일
-
- dispatch(
- openScheduleCreateModal({
- type: UI_TYPE.PERSONAL_SCHEDULE,
- }),
- )
- }
- >
-
- 일정 추가
-
-
- 하루동안의 할 일을 관리합니다.
-
- {todaySchedules.map((s) => {
- return (
-
- );
- })}
-
- >
- )}
-
-
- {(openedModal === UI_TYPE.PERSONAL_SCHEDULE ||
- openedModal === UI_TYPE.SHARE_SCHEDULE) && (
-
- )}
-
-
- );
-};
-
-export default ShareTodoList;
diff --git a/src/components/SharePage/ShareTodoList/ShareTodoList.styles.js b/src/components/SharePage/ShareTodoList/ShareTodoList.styles.js
deleted file mode 100644
index 6e2cce0ae..000000000
--- a/src/components/SharePage/ShareTodoList/ShareTodoList.styles.js
+++ /dev/null
@@ -1,173 +0,0 @@
-import styled from "styled-components";
-
-export const Wrapper = styled.div`
- display: flex;
- flex-direction: column;
- .invite {
- margin: 1rem 0;
- width: 100%;
- .container {
- display: flex;
- .box {
- height: 2rem;
- width: 80%;
- border: 2px solid #000;
- }
- }
- }
-`;
-
-export const TodoContainer = styled.div`
- width: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
- flex-direction: column;
- max-width: 450px;
- height: 100%;
- background-color: #f5f5f5;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- border-radius: 15px;
- font-family: "Inter";
- padding: 20px;
-`;
-
-export const TodoHeader = styled.div`
- display: flex;
- margin-bottom: 20px;
- width: 100%;
- background-color: #f5f5f5;
-`;
-
-export const TodoTabs = styled.div`
- width: 100%;
- display: flex;
- height: 40px;
- border-bottom: 1px solid #e5e5e5;
-`;
-
-export const TodoTab = styled.button`
- display: flex;
- align-items: center;
- justify-content: center;
- width: 50%;
- background: ${(props) => (props.selected ? "#A495FF" : "white")};
- border: 1px solid #e5e5e5;
- border-bottom: none;
- border-radius: 5px;
- font-size: 16px;
- font-weight: 600;
- cursor: pointer;
- color: ${(props) => (props.selected ? "white" : "#121127")};
- opacity: ${(props) => (props.selected ? "1" : "0.7")};
- transition: opacity 0.3s ease, background 0.3s ease, color 0.3s ease;
- padding: 10px 0;
-
- &:hover,
- &[aria-selected="true"] {
- opacity: 1;
- background: #6c55fe;
- color: white;
- }
-`;
-
-export const AddEventButton = styled.button`
- display: flex;
- align-items: center;
- background: none;
- border: none;
- font-size: 14px;
- font-weight: 600;
- cursor: pointer;
- color: #6c55fe;
- transition: opacity 0.3s ease;
-
- &:hover {
- opacity: 0.7;
- }
-`;
-
-export const TodoBody = styled.div`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- background-color: #ffffff;
- height: auto;
- width: 100%;
- border-radius: 15px;
- padding: 20px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
-`;
-
-export const TodoTitle = styled.h2`
- display: flex;
- justify-content: space-between;
- align-items: center;
- width: 100%;
- font-weight: 700;
- font-size: 24px;
- line-height: 24px;
- color: #313131;
- margin-top: 0px;
- margin-bottom: 10px;
-`;
-
-export const TodoSubtitle = styled.h3`
- font-weight: 500;
- font-size: 14px;
- line-height: 20px;
- color: #777;
- margin-bottom: 20px;
-`;
-
-export const TodoButton = styled.button`
- width: 100%;
- height: 200px;
- display: flex;
- justify-content: center;
- align-items: center;
- background: white;
- border-radius: 10px;
- border: 1px solid #6c55fe;
- font-size: 14px;
- font-weight: 600;
- line-height: 16px;
- cursor: pointer;
- color: #30374f;
- margin-top: 20px;
- transition: opacity 0.3s ease;
-
- &:hover {
- opacity: 0.7;
- }
-`;
-
-export const TodoList = styled.ul`
- width: 100%;
- height: 400px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-start;
- border-radius: 10px;
- gap: 20px;
- padding: 0;
- margin: 0;
- overflow-y: auto;
-
- &::-webkit-scrollbar {
- width: 10px;
- }
-
- &::-webkit-scrollbar-track {
- background: #f1f1f1;
- }
-
- &::-webkit-scrollbar-thumb {
- background: #888;
- }
-
- &::-webkit-scrollbar-thumb:hover {
- background: #555;
- }
-`;
diff --git a/src/constants/calendarConstants.js b/src/constants/calendarConstants.js
index 3688746a2..2ce6b1f32 100644
--- a/src/constants/calendarConstants.js
+++ b/src/constants/calendarConstants.js
@@ -1,33 +1,36 @@
-export const SCHEDULE_TYPE = {
- SHARED: "SHARE_SCHEDULE",
- PERSONAL: "PERSONAL_SCHEDULE",
-};
-
export const VIEW_TYPE = {
// DAY_GRID_WEEK: "dayGridWeek",
DAY_GRID_WEEK: "timeGridWeek",
DAY_GRID_MONTH: "dayGridMonth",
};
-export const CALENDAR_USER_COLORS = [
- "#669900",
- "#99cc33",
- "#ccee66",
- "#006699",
- "#3399cc",
- "#990066",
- "#cc3399",
- "#ff6600",
- "#ff9900",
- "#ffcc00",
- "#d00000",
- "#ffba08",
- "#cbff8c",
- "#8fe388",
+export const SCHEDULE_PAGE_TYPE = {
+ PERSONAL: "personal",
+ SHARED: "shared",
+};
+
+export const SCHEDULE_COLORS = [
"#1b998b",
- "#3185fc",
- "#5d2e8c",
"#46237a",
- "#ff7b9c",
- "#ff9b85",
+ "#ff9a84",
+ "#caff8b",
+ "#3186fd",
+
+ "#980065",
+ "#ff9900",
+ "#669900",
+ "#3398cc",
+ "#ccee66",
+
+ "#fe7b9b",
+ "#sd2e8c",
+ "#8fe489",
+ "#f2a007",
+ "#d90404",
+
+ "#bf349a",
+ "#3498bf",
+ "#92bf30",
+ "#f2b705",
+ "#f25c05",
];
diff --git a/src/constants/iconConstants.js b/src/constants/iconConstants.js
index 7a96ccdc9..16f232c6c 100644
--- a/src/constants/iconConstants.js
+++ b/src/constants/iconConstants.js
@@ -2,17 +2,20 @@ import AccessArrowIcon from "@/assets/icon/ic-access-arrow.svg";
import InfoIcon from "@/assets/icon/ic-access-info-circle.svg";
import AccessInfoIcon from "@/assets/icon/ic-access-info.svg";
import AdminIcon from "@/assets/icon/ic-admin.svg";
+import BackArrowIcon from "@/assets/icon/ic-back-arrow.svg";
import CloseIcon from "@/assets/icon/ic-close.svg";
import CommentListIcon from "@/assets/icon/ic-comment-list.svg";
import CrownIcon from "@/assets/icon/ic-crown.svg";
import DeleteScheduleIcon from "@/assets/icon/ic-delete-schedule.svg";
import DocumentIcon from "@/assets/icon/ic-document.svg";
+import DottedCalendarIcon from "@/assets/icon/ic-dotted-calendar.svg";
import DownArrowIcon from "@/assets/icon/ic-down-arrow.svg";
import EditScheduleIcon from "@/assets/icon/ic-edit-schedule.svg";
import EmptyFeedIcon from "@/assets/icon/ic-empty-feed.svg";
import EmptyGroupListIcon from "@/assets/icon/ic-empty-group-list.svg";
import EmptyGroupMemberIcon from "@/assets/icon/ic-empty-group-member.svg";
import EmptyMyGroupIcon from "@/assets/icon/ic-empty-my-group.svg";
+import ExtraMembersDropdownIcon from "@/assets/icon/ic-extraMembers-dropdown.svg";
import CommentIcon from "@/assets/icon/ic-feed-comment.svg";
import EmptyHeartIcon from "@/assets/icon/ic-feed-heart-empty.svg";
import FillHeartIcon from "@/assets/icon/ic-feed-heart-fill.svg";
@@ -40,6 +43,7 @@ import SearchIcon from "@/assets/icon/ic-search.svg";
import SecretIcon from "@/assets/icon/ic-secret.svg";
import SelodyLogoIcon from "@/assets/icon/ic-selody-logo.svg";
import ViewerIcon from "@/assets/icon/ic-viewer.svg";
+import WhitePlusIcon from "@/assets/icon/ic-white-plus.svg";
export {
SearchIcon,
@@ -84,4 +88,8 @@ export {
EmptyGroupListIcon,
FeedImgIcon,
FeedImgCloseIcon,
+ DottedCalendarIcon,
+ WhitePlusIcon,
+ ExtraMembersDropdownIcon,
+ BackArrowIcon,
};
diff --git a/src/constants/uiConstants.js b/src/constants/uiConstants.js
index b6633d992..73b49d6b7 100644
--- a/src/constants/uiConstants.js
+++ b/src/constants/uiConstants.js
@@ -13,10 +13,12 @@ export const UI_TYPE = {
MEMBER_MODAL: "MEMBER_MODAL",
DELETE_MEMBER_WARNING_MODAL: "DELETE_MEMBER_WARNING_MODAL",
FEED_DETAIL_MODAL: `FEED_DETAIL_MODAL`,
+ EMPTY_GROUP_NOTIFICATION: "EMPTY_GROUP_NOTIFICATION",
};
export const SCHEDULE_MODAL_TYPE = {
EDIT: "EDIT",
CREATE: "CREATE",
VIEW: "VIEW",
+ PROPOSAL: "PROPOSAL",
};
diff --git a/src/features/schedule/schedule-service.js b/src/features/schedule/schedule-service.js
index 5c7637742..2bb24ceb3 100644
--- a/src/features/schedule/schedule-service.js
+++ b/src/features/schedule/schedule-service.js
@@ -1,14 +1,54 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import moment from "moment";
-import { VIEW_TYPE } from "@/constants/calendarConstants";
+import { SCHEDULE_PAGE_TYPE, VIEW_TYPE } from "@/constants/calendarConstants";
import commonThunk from "@/features/commonThunk";
+import { closeModal } from "@/features/ui/ui-slice";
import { getFirstDateOfWeek } from "@/utils/calendarUtils";
import { convertScheduleFormValueToData } from "@/utils/convertSchedule";
+import convertToUTC from "@/utils/convertToUTC";
+
+export const getGroupScheduleProposal = createAsyncThunk(
+ "schedule/getGroupScheduleProposal",
+ async (_, thunkAPI) => {
+ const {
+ schedule: { currentGroupScheduleId, currentPageType },
+ } = thunkAPI.getState();
+
+ if (
+ currentPageType !== SCHEDULE_PAGE_TYPE.SHARED ||
+ !currentGroupScheduleId
+ ) {
+ return thunkAPI.rejectWithValue("잘못된 일정 후보 요청 형식입니다.");
+ }
+
+ const data = await commonThunk(
+ {
+ method: "GET",
+ url: `/api/group/${currentGroupScheduleId}/proposal/list`,
+ successCode: 200,
+ },
+ thunkAPI,
+ );
+
+ return data;
+ },
+);
export const getTodaySchedules = createAsyncThunk(
"schedule/getTodaySchedules",
async (_, thunkAPI) => {
+ const {
+ schedule: { currentGroupScheduleId, currentPageType },
+ } = thunkAPI.getState();
+
+ if (
+ currentPageType === SCHEDULE_PAGE_TYPE.SHARED &&
+ !currentGroupScheduleId
+ ) {
+ return thunkAPI.rejectWithValue("잘못된 오늘 일정 요청 형식입니다.");
+ }
+
const today = new Date();
today.setHours(0, 0, 0, 0);
const startDateTime = today.toISOString();
@@ -19,7 +59,11 @@ export const getTodaySchedules = createAsyncThunk(
const data = await commonThunk(
{
method: "GET",
- url: "/api/user/calendar",
+ url: `/api/${
+ currentPageType === SCHEDULE_PAGE_TYPE.PERSONAL
+ ? "user"
+ : `group/${currentGroupScheduleId}`
+ }/calendar`,
params: {
startDateTime,
endDateTime,
@@ -35,6 +79,17 @@ export const getTodaySchedules = createAsyncThunk(
export const getSchedulesForTheWeek = createAsyncThunk(
"schedule/getSchedulesForTheWeek",
async (_, thunkAPI) => {
+ const {
+ schedule: { currentGroupScheduleId, currentPageType },
+ } = thunkAPI.getState();
+
+ if (
+ currentPageType === SCHEDULE_PAGE_TYPE.SHARED &&
+ !currentGroupScheduleId
+ ) {
+ return thunkAPI.rejectWithValue("잘못된 오늘 일정 요청 형식입니다.");
+ }
+
const today = new Date();
today.setHours(0, 0, 0, 0);
const startDateTime = new Date(
@@ -47,7 +102,11 @@ export const getSchedulesForTheWeek = createAsyncThunk(
const data = await commonThunk(
{
method: "GET",
- url: "/api/user/calendar",
+ url: `/api/${
+ currentPageType === SCHEDULE_PAGE_TYPE.PERSONAL
+ ? "user"
+ : `group/${currentGroupScheduleId}`
+ }/calendar`,
params: {
startDateTime,
endDateTime,
@@ -62,7 +121,18 @@ export const getSchedulesForTheWeek = createAsyncThunk(
export const getSchedulesSummary = createAsyncThunk(
"schedule/getSchedulesSummary",
- async ({ isGroup, groupId }, thunkAPI) => {
+ async (_, thunkAPI) => {
+ const {
+ schedule: { currentGroupScheduleId, currentPageType },
+ } = thunkAPI.getState();
+
+ if (
+ currentPageType === SCHEDULE_PAGE_TYPE.SHARED &&
+ !currentGroupScheduleId
+ ) {
+ return thunkAPI.rejectWithValue("잘못된 오늘 일정 요청 형식입니다.");
+ }
+
const state = thunkAPI.getState();
const { currentYear, currentMonth, currentWeek, currentCalendarView } =
state.schedule;
@@ -94,15 +164,14 @@ export const getSchedulesSummary = createAsyncThunk(
: firstDateOfWeek + 6,
).toISOString();
- if ((isGroup && !groupId) || (!isGroup && groupId))
- throw new Error(
- "isGroup일 때는 groupId가 필수이며, isGroup이 아닐 때는 groupId가 필요 없습니다.",
- );
-
const data = await commonThunk(
{
method: "GET",
- url: `/api/${!isGroup ? "user" : `group/${groupId}`}/calendar/summary`,
+ url: `/api/${
+ currentPageType === SCHEDULE_PAGE_TYPE.PERSONAL
+ ? "user"
+ : `group/${currentGroupScheduleId}`
+ }/calendar/summary`,
params: {
startDateTime,
endDateTime,
@@ -211,3 +280,79 @@ ${moment(end).format(`${yearFormat}MM월 DD일 HH시 mm분`)}`;
return { schedules: data.schedules, title };
},
);
+
+export const getScheduleProposals = createAsyncThunk(
+ "schedule/getScheduleProposals",
+ async (
+ { startDateStr, endDateStr, startTimeStr, endTimeStr, minDuration },
+ thunkAPI,
+ ) => {
+ const groupId = thunkAPI.getState().schedule.currentGroupScheduleId;
+
+ if (!startDateStr || !endDateStr || !minDuration || !groupId) {
+ return thunkAPI.rejectWithValue("잘못된 데이터 형식입니다.");
+ }
+
+ const data = await commonThunk(
+ {
+ method: "GET",
+ url: `/api/group/${groupId}/proposals?startDateTime=${convertToUTC(
+ startDateStr,
+ startTimeStr,
+ )}&endDateTime=${convertToUTC(
+ endDateStr,
+ endTimeStr,
+ )}&duration=${minDuration}`,
+ successCode: 200,
+ },
+ thunkAPI,
+ );
+
+ return data;
+ },
+);
+
+export const enrollScheudleProposals = createAsyncThunk(
+ "schedule/enrollScheudleProposals",
+ async ({ title, content, selectedRecommendationIndexes }, thunkAPI) => {
+ const { currentGroupScheduleId: groupId, recommendedScheduleProposals } =
+ thunkAPI.getState().schedule;
+
+ if (
+ !title ||
+ !content ||
+ selectedRecommendationIndexes.length === 0 ||
+ !groupId
+ ) {
+ return thunkAPI.rejectWithValue("잘못된 데이터 형식입니다.");
+ }
+
+ try {
+ const promises = selectedRecommendationIndexes.map(
+ async (proposalIndex) => {
+ const body = convertScheduleFormValueToData({
+ title,
+ content,
+ ...recommendedScheduleProposals[proposalIndex],
+ });
+ delete body.requestStartDateTime;
+ delete body.requestEndDateTime;
+ return commonThunk(
+ {
+ method: "POST",
+ url: `/api/group/${groupId}/proposal`,
+ data: body,
+ successCode: 200,
+ },
+ thunkAPI,
+ );
+ },
+ );
+ const result = await Promise.all(promises);
+ thunkAPI.dispatch(closeModal());
+ return result;
+ } catch (error) {
+ return thunkAPI.rejectWithValue("일정 후보 등록 실패");
+ }
+ },
+);
diff --git a/src/features/schedule/schedule-slice.js b/src/features/schedule/schedule-slice.js
index 6a67a343b..f20900b85 100644
--- a/src/features/schedule/schedule-slice.js
+++ b/src/features/schedule/schedule-slice.js
@@ -1,9 +1,15 @@
import { toast } from "react-toastify";
import { createSlice, isAnyOf } from "@reduxjs/toolkit";
+import _ from "lodash";
-import { VIEW_TYPE } from "@/constants/calendarConstants.js";
+import {
+ SCHEDULE_PAGE_TYPE,
+ VIEW_TYPE,
+} from "@/constants/calendarConstants.js";
+import { inqueryUserGroup } from "@/features/user/user-service.js";
import { getCurrentWeek } from "@/utils/calendarUtils.js";
+import { convertScheduleDataToFormValue } from "@/utils/convertSchedule.js";
import {
createSchedule,
@@ -13,12 +19,46 @@ import {
updateSchedule,
deleteSchedule,
getOverlappedSchedules,
+ getGroupScheduleProposal,
+ getScheduleProposals,
+ enrollScheudleProposals,
} from "./schedule-service.js";
const initialOverlappedScheduleInfo = { title: "", schedules: [] };
+// lodash만으로 state 내 배열(Proxy(array))과 그냥 객체 내 배열 간의 비교가 안돼서 따로 작성함
+const checkTowFormsAreDifferent = (prevState, curr) => {
+ let isDifferent = false;
+ const keys = Object.keys(prevState);
+ for (let i = 0; i < keys.length; i += 1) {
+ const key = keys[i];
+ // byweekday가 아닐 때
+ if (key !== "byweekday") {
+ if (prevState[key] !== curr[key]) {
+ isDifferent = true;
+ break;
+ }
+ } else {
+ // byweekday일 때
+ const prevByweekday = [...prevState[key]];
+ const currByweekday = [...curr[key]];
+ // byweekday는 순서가 무작위이므로 정렬 후 비교
+ prevByweekday.sort();
+ currByweekday.sort();
+ if (!_.isEqual(prevByweekday, currByweekday)) {
+ isDifferent = true;
+ break;
+ }
+ }
+ }
+ return isDifferent;
+};
+
const initialState = {
calendarSchedules: [],
+ currentGroupScheduleId: null,
+ scheduleProposals: [],
+ recommendedScheduleProposals: [],
todaySchedules: [],
schedulesForTheWeek: [],
overlappedScheduleInfo: initialOverlappedScheduleInfo,
@@ -27,6 +67,7 @@ const initialState = {
currentWeek: getCurrentWeek(),
isLoading: false,
currentCalendarView: VIEW_TYPE.DAY_GRID_MONTH,
+ currentPageType: SCHEDULE_PAGE_TYPE.PERSONAL,
};
const scheduleSlice = createSlice({
@@ -42,11 +83,6 @@ const scheduleSlice = createSlice({
setCurrentWeek: (state, { payload }) => {
state.currentWeek = payload;
},
- resetCurrentDate: (state) => {
- state.currentYear = new Date().getFullYear();
- state.currentMonth = new Date().getMonth() + 1;
- state.currentWeek = getCurrentWeek();
- },
setCurrentCalenderView: (state, { payload }) => {
if (
payload !== VIEW_TYPE.DAY_GRID_MONTH &&
@@ -60,6 +96,28 @@ const scheduleSlice = createSlice({
resetOverlappedSchedules: (state) => {
state.overlappedScheduleInfo = initialOverlappedScheduleInfo;
},
+ changeSchedulePage: (state, { payload }) => {
+ if (
+ payload !== SCHEDULE_PAGE_TYPE.PERSONAL &&
+ payload !== SCHEDULE_PAGE_TYPE.SHARED
+ ) {
+ throw new Error("잘못된 페이지 타입입니다.");
+ }
+ state.currentPageType = payload;
+ },
+ changeCurrentGroupId: (state, { payload }) => {
+ state.currentGroupScheduleId = payload;
+ },
+ changeRecommendedProposal: (state, { payload: { formValues, index } }) => {
+ // 이를 dispatch하는 onSubmit handler에서 중복 검사함
+ state.recommendedScheduleProposals.splice(index, 1, formValues);
+ },
+ resetRecommendedScheduleProposals: (state) => {
+ state.recommendedScheduleProposals = [];
+ },
+ resetSchedule: () => {
+ return initialState;
+ },
},
extraReducers: (builder) => {
@@ -202,6 +260,41 @@ const scheduleSlice = createSlice({
.addCase(getOverlappedSchedules.rejected, (state) => {
state.overlappedScheduleInfo = initialOverlappedScheduleInfo;
})
+ .addCase(getGroupScheduleProposal.fulfilled, (state, { payload }) => {
+ state.scheduleProposals = payload;
+ })
+ .addCase(getScheduleProposals.fulfilled, (state, { payload }) => {
+ payload.proposals.forEach((proposal) => {
+ const proposalFormValue = {
+ ...convertScheduleDataToFormValue(proposal),
+ };
+ delete proposalFormValue.id;
+ delete proposalFormValue.userId;
+ delete proposalFormValue.title;
+ delete proposalFormValue.content;
+
+ const sameProposalIndex =
+ state.recommendedScheduleProposals.findIndex(
+ (prevProposal) =>
+ !checkTowFormsAreDifferent(prevProposal, proposalFormValue),
+ );
+ if (sameProposalIndex === -1) {
+ state.recommendedScheduleProposals.push(proposalFormValue);
+ }
+ });
+ })
+ // userGroup 업데이트 시
+ .addCase(inqueryUserGroup.fulfilled, (state, { payload }) => {
+ if (payload.length > 0 && !state.currentGroupScheduleId) {
+ state.currentGroupScheduleId = payload[0].groupId;
+ }
+ })
+ .addCase(enrollScheudleProposals.fulfilled, (state, { payload }) => {
+ state.scheduleProposals.push(...payload);
+ })
+ .addCase(enrollScheudleProposals.rejected, () => {
+ toast.error("일정 후보 등록에 실패했습니다.");
+ })
.addMatcher(
isAnyOf(
createSchedule.pending,
@@ -211,6 +304,9 @@ const scheduleSlice = createSlice({
updateSchedule.pending,
deleteSchedule.pending,
getOverlappedSchedules.pending,
+ getGroupScheduleProposal.pending,
+ getScheduleProposals.pending,
+ enrollScheudleProposals.pending,
),
(state) => {
state.isLoading = true;
@@ -225,6 +321,9 @@ const scheduleSlice = createSlice({
updateSchedule.fulfilled,
deleteSchedule.fulfilled,
getOverlappedSchedules.fulfilled,
+ getGroupScheduleProposal.fulfilled,
+ getScheduleProposals.fulfilled,
+ enrollScheudleProposals.fulfilled,
),
(state) => {
state.isLoading = false;
@@ -239,6 +338,9 @@ const scheduleSlice = createSlice({
updateSchedule.rejected,
deleteSchedule.rejected,
getOverlappedSchedules.rejected,
+ getGroupScheduleProposal.rejected,
+ getScheduleProposals.rejected,
+ enrollScheudleProposals.rejected,
),
(state) => {
state.isLoading = false;
@@ -251,9 +353,13 @@ export const {
setCurrentYear,
setCurrentMonth,
setCurrentWeek,
- resetCurrentDate,
setCurrentCalenderView,
resetOverlappedSchedules,
+ changeSchedulePage,
+ changeCurrentGroupId,
+ changeRecommendedProposal,
+ resetRecommendedScheduleProposals,
+ resetSchedule,
} = scheduleSlice.actions;
export default scheduleSlice.reducer;
diff --git a/src/features/ui/ui-slice.js b/src/features/ui/ui-slice.js
index 5155a453c..ae7e6c050 100644
--- a/src/features/ui/ui-slice.js
+++ b/src/features/ui/ui-slice.js
@@ -61,6 +61,11 @@ const uiSlice = createSlice({
state.scheduleModalMode = SCHEDULE_MODAL_TYPE.VIEW;
state.scheduleModalId = id;
},
+ openScheduleProposalModal: (state) => {
+ state.openedModal = UI_TYPE.SHARE_SCHEDULE;
+ state.scheduleModalMode = SCHEDULE_MODAL_TYPE.PROPOSAL;
+ state.isLoading = false;
+ },
openCreateGroupModal: (state) => {
state.openedModal = UI_TYPE.CREATE_GROUP;
},
@@ -99,6 +104,9 @@ const uiSlice = createSlice({
state.feedDetailModalId = payload;
state.openedModal = UI_TYPE.FEED_DETAIL_MODAL;
},
+ openEmptyGroupNotificationModal: (state) => {
+ state.openedModal = UI_TYPE.EMPTY_GROUP_NOTIFICATION;
+ },
closeModal: () => {
return initialState;
},
@@ -116,6 +124,7 @@ export const {
openScheduleCreateModal,
openScheduleEditModal,
openScheduleViewModal,
+ openScheduleProposalModal,
openCreateGroupModal,
closeModal,
setIsLoading,
@@ -130,6 +139,7 @@ export const {
openMemberRequestModal,
openDeleteMemberWarningModal,
openFeedDetailModal,
+ openEmptyGroupNotificationModal,
} = uiSlice.actions;
export default uiSlice.reducer;
diff --git a/src/pages/GroupSchedulePage.jsx b/src/pages/GroupSchedulePage.jsx
deleted file mode 100644
index c52ff5054..000000000
--- a/src/pages/GroupSchedulePage.jsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from "react";
-
-import styled from "styled-components";
-
-import CalendarContainer from "@/components/Common/CalendarContainer/CalendarContainer";
-import { SCHEDULE_TYPE } from "@/constants/calendarConstants";
-
-import ShareTodoList from "../components/SharePage/ShareTodoList/ShareTodoList";
-
-const MainContainer = styled.main`
- display: flex;
- justify-content: center;
- padding: 50px 60px 0;
- font-family: "Inter", sans-serif;
-`;
-
-const GroupSchedulePage = () => {
- // const dispatch = useDispatch();
-
- // useEffect(() => {
- // dispatch(getGroupList());
- // }, []);
-
- return (
-
-
-
-
- );
-};
-
-export default GroupSchedulePage;
diff --git a/src/pages/PersonalSchedulePage.jsx b/src/pages/PersonalSchedulePage.jsx
new file mode 100644
index 000000000..544ab9001
--- /dev/null
+++ b/src/pages/PersonalSchedulePage.jsx
@@ -0,0 +1,61 @@
+import React, { useEffect } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { toast } from "react-toastify";
+
+import EmptyUserGroupNotificationModal from "@/components/Common/Modal/EmptyUserGroupNotificationModal/EmptyUserGroupNotificationModal";
+import ScheduleModal from "@/components/Common/ScheduleModal/ScheduleModal";
+import CalendarContainer from "@/components/Common/SchedulePage/CalendarContainer/CalendarContainer";
+import ScheduleItemList from "@/components/Common/SchedulePage/ScheduleItemList/ScheduleItemList";
+import { LayoutMain } from "@/components/Common/SchedulePage/SchedulePageLayout.styles";
+import { SCHEDULE_PAGE_TYPE, VIEW_TYPE } from "@/constants/calendarConstants";
+import { UI_TYPE } from "@/constants/uiConstants";
+import {
+ getSchedulesForTheWeek,
+ getSchedulesSummary,
+ getTodaySchedules,
+} from "@/features/schedule/schedule-service";
+import {
+ changeSchedulePage,
+ resetSchedule,
+} from "@/features/schedule/schedule-slice";
+
+const PersonalSchedulePage = () => {
+ const dispatch = useDispatch();
+ const openedModal = useSelector(({ ui }) => ui.openedModal);
+ const currentCalendarView = useSelector(
+ ({ schedule }) => schedule.currentCalendarView,
+ );
+
+ useEffect(() => {
+ const getPersonalPageInfo = async () => {
+ await dispatch(changeSchedulePage(SCHEDULE_PAGE_TYPE.PERSONAL));
+ toast.dismiss();
+ toast.loading("개인 일정을 가져오는 중...");
+ dispatch(getSchedulesSummary());
+ dispatch(getTodaySchedules());
+ dispatch(getSchedulesForTheWeek());
+ toast.dismiss();
+ };
+ getPersonalPageInfo();
+ return () => {
+ dispatch(resetSchedule());
+ };
+ }, []);
+
+ return (
+ <>
+
+
+
+
+ {openedModal === UI_TYPE.PERSONAL_SCHEDULE && (
+
+ )}
+ {openedModal === UI_TYPE.EMPTY_GROUP_NOTIFICATION && (
+
+ )}
+ >
+ );
+};
+
+export default PersonalSchedulePage;
diff --git a/src/pages/PersonalSchedulePage/PersonalSchedulePage.jsx b/src/pages/PersonalSchedulePage/PersonalSchedulePage.jsx
deleted file mode 100644
index a134e2da2..000000000
--- a/src/pages/PersonalSchedulePage/PersonalSchedulePage.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from "react";
-import { useSelector } from "react-redux";
-
-import CalendarContainer from "@/components/Common/CalendarContainer/CalendarContainer";
-import ScheduleModal from "@/components/Common/ScheduleModal/ScheduleModal";
-import ScheduleItemList from "@/components/ScheduleItemList/ScheduleItemList";
-import { SCHEDULE_TYPE, VIEW_TYPE } from "@/constants/calendarConstants";
-import { UI_TYPE } from "@/constants/uiConstants";
-
-import { LayoutMain } from "./PersonalSchedulePage.styles";
-
-const PersonalSchedulePage = () => {
- const openedModal = useSelector(({ ui }) => ui.openedModal);
- const currentCalendarView = useSelector(
- ({ schedule }) => schedule.currentCalendarView,
- );
-
- return (
- <>
-
-
-
-
- {openedModal === UI_TYPE.PERSONAL_SCHEDULE && (
-
- )}
- >
- );
-};
-
-export default PersonalSchedulePage;
diff --git a/src/pages/SharedSchedulePage.jsx b/src/pages/SharedSchedulePage.jsx
new file mode 100644
index 000000000..1b85de7d2
--- /dev/null
+++ b/src/pages/SharedSchedulePage.jsx
@@ -0,0 +1,65 @@
+import React, { useEffect } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { toast } from "react-toastify";
+
+import CalendarContainer from "@/components/Common/SchedulePage/CalendarContainer/CalendarContainer";
+import ScheduleItemList from "@/components/Common/SchedulePage/ScheduleItemList/ScheduleItemList";
+import { LayoutMain } from "@/components/Common/SchedulePage/SchedulePageLayout.styles";
+import ScheduleProposalModal from "@/components/Common/ScheduleProposalModal/ScheduleProposalModal";
+import { SCHEDULE_PAGE_TYPE, VIEW_TYPE } from "@/constants/calendarConstants";
+import { SCHEDULE_MODAL_TYPE, UI_TYPE } from "@/constants/uiConstants";
+import {
+ getGroupScheduleProposal,
+ getSchedulesForTheWeek,
+ getSchedulesSummary,
+ getTodaySchedules,
+} from "@/features/schedule/schedule-service";
+import {
+ changeSchedulePage,
+ resetSchedule,
+} from "@/features/schedule/schedule-slice";
+import { inqueryUserGroup } from "@/features/user/user-service";
+
+const SharedSchedulePage = () => {
+ const dispatch = useDispatch();
+ const { currentCalendarView, currentGroupScheduleId } = useSelector(
+ ({ schedule }) => schedule,
+ );
+ const { openedModal, scheduleModalMode } = useSelector(({ ui }) => ui);
+
+ useEffect(() => {
+ const getSharedSchedulePreset = async () => {
+ await dispatch(changeSchedulePage(SCHEDULE_PAGE_TYPE.SHARED));
+ await dispatch(inqueryUserGroup());
+ };
+ getSharedSchedulePreset();
+ return () => {
+ dispatch(resetSchedule());
+ };
+ }, []);
+
+ useEffect(() => {
+ if (currentGroupScheduleId) {
+ toast.dismiss();
+ toast.loading("공유 일정을 가져오는 중...");
+ dispatch(getSchedulesSummary());
+ dispatch(getTodaySchedules());
+ dispatch(getSchedulesForTheWeek());
+ dispatch(getGroupScheduleProposal());
+ toast.dismiss();
+ }
+ }, [currentGroupScheduleId]);
+
+ return (
+
+
+
+ {openedModal === UI_TYPE.SHARE_SCHEDULE &&
+ scheduleModalMode === SCHEDULE_MODAL_TYPE.PROPOSAL && (
+
+ )}
+
+ );
+};
+
+export default SharedSchedulePage;
diff --git a/src/pages/index.jsx b/src/pages/index.jsx
index 5d36c4ba2..206606c07 100644
--- a/src/pages/index.jsx
+++ b/src/pages/index.jsx
@@ -1,13 +1,13 @@
import CommunityPage from "./CommunityPage/CommunityPage";
import ErrorPage from "./ErrorPage/ErrorPage";
import GroupPage from "./GroupPage/GroupPage";
-import GroupSchedulePage from "./GroupSchedulePage";
import LandingPage from "./LandingPage/LandingPage";
import LoginPage from "./LoginPage/LoginPage";
import MyPage from "./MyPage/MyPage";
import PersonalSchedulePage from "./PersonalSchedulePage/PersonalSchedulePage";
import Root from "./Root";
import SettingPage from "./SettingPage/SettingPage";
+import SharedSchedulePage from "./SharedSchedulePage";
import SignUpPage from "./SignUpPage/SignUpPage";
export {
@@ -17,7 +17,7 @@ export {
Root,
SignUpPage,
PersonalSchedulePage,
- GroupSchedulePage,
+ SharedSchedulePage,
SettingPage,
CommunityPage,
GroupPage,
diff --git a/src/utils/calendarUtils.js b/src/utils/calendarUtils.js
index d4613d403..e620234bd 100644
--- a/src/utils/calendarUtils.js
+++ b/src/utils/calendarUtils.js
@@ -1,6 +1,10 @@
+import { toast } from "react-toastify";
+
import moment from "moment";
+import { getRecurringString } from "@/components/Common/ScheduleModal/RepeatDetail/RepeatDetail";
import customFetch from "@/components/UI/BaseAxios";
+import { SCHEDULE_COLORS } from "@/constants/calendarConstants";
import convertToUTC from "@/utils/convertToUTC";
// 리스트(주마다 보기)로 진행했을 떄 보여줄 첫 일요일을 계산합니다.
@@ -80,3 +84,262 @@ export const getSchedule = async (
console.log(error);
}
};
+
+/**
+ response는 배열이며, owner인 유저가 배열 첫 번쨰 인덱스입니다.
+*/
+export const getGroupMembers = async (onFulfilled, groupId) => {
+ if (typeof onFulfilled !== "function") {
+ throw new Error("onFullfilled 이벤트 리스너가 필요합니다.");
+ }
+ if (!groupId) {
+ throw new Error("조회하려는 group의 Id가 필요합니다.");
+ }
+ try {
+ const response = await customFetch.get(`/api/group/${groupId}/members`);
+ response.data.sort((member) => (member.accessLevel === "owner" ? -1 : 1));
+ onFulfilled(response.data);
+ } catch (error) {
+ console.log(error);
+ }
+};
+
+export const getGroupColor = (groupId) => {
+ return SCHEDULE_COLORS[groupId % 20];
+};
+
+export const getTimeString = (start, end, isAlldayValue) => {
+ const isAllday = checkIsAlldaySchedule(start, end) || isAlldayValue;
+ const startDate = new Date(start);
+ const endDate = new Date(end);
+ const startDateString = `${
+ startDate.getMonth() + 1
+ }월 ${startDate.getDate()}일`;
+ if (isAllday) {
+ return `${startDateString} 하루 종일`;
+ }
+
+ const startTimeString = `${
+ startDate.getHours() < 10
+ ? `0${startDate.getHours()}`
+ : startDate.getHours()
+ }:${
+ startDate.getMinutes() < 10
+ ? `0${startDate.getMinutes()}`
+ : startDate.getMinutes()
+ }`;
+ let endDateString = `${endDate.getMonth() + 1}월 ${endDate.getDate()}일`;
+ const endTimeString = `${
+ // eslint-disable-next-line no-nested-ternary
+ !endDate.getHours() && !endDate.getMinutes()
+ ? 24
+ : endDate.getHours() < 10
+ ? `0${endDate.getHours()}`
+ : endDate.getHours()
+ }:${
+ endDate.getMinutes() < 10
+ ? `0${endDate.getMinutes()}`
+ : endDate.getMinutes()
+ }`;
+
+ const isOnlyToday = startDate.toDateString() === endDate.toDateString();
+
+ if (isOnlyToday || checkIsAlldaySchedule(start, end)) {
+ endDateString = null;
+ }
+
+ return `${startDateString} ${startTimeString} ~ ${
+ endDateString || ""
+ } ${endTimeString}`;
+};
+
+export const calculateMinUntilDateString = (
+ startDateStr,
+ freq,
+ intervalValue,
+ isInfinite = false,
+) => {
+ const interval = Math.floor(
+ Number(intervalValue) > 0 ? Number(intervalValue) : 1,
+ );
+ if (typeof startDateStr !== "string") {
+ throw new Error(
+ `startDateStr은 문자열 타입이어야 합니다. 현재 값은 ${startDateStr}입니다.`,
+ );
+ }
+ if (startDateStr.trim() === "") {
+ throw new Error(
+ `startDateStr은 빈 문자열이 아니어야 합니다. 현재 값은 비어있습니다.`,
+ );
+ }
+
+ if (freq === "NONE" || isInfinite) {
+ return "";
+ }
+
+ const startDate = new Date(startDateStr);
+ let untilDate = "";
+
+ if (freq === "DAILY" || freq === "DAILY_N") {
+ untilDate = startDate.setDate(startDate.getDate() + interval + 1);
+ } else if (freq === "WEEKLY" || freq === "WEEKLY_N") {
+ untilDate = startDate.setDate(startDate.getDate() + 7 * interval + 1);
+ } else if (freq === "MONTHLY" || freq === "MONTHLY_N") {
+ startDate.setMonth(startDate.getMonth() + interval);
+ untilDate = startDate.setDate(startDate.getDate() + 1);
+ } else if (freq === "YEARLY" || freq === "YEARLY_N") {
+ startDate.setFullYear(startDate.getFullYear() + interval);
+ untilDate = startDate.setDate(startDate.getDate() + 1);
+ }
+ return new Date(untilDate).toISOString().slice(0, 10);
+};
+
+export const setByweekday = (weekNum, prev, checked) => {
+ if (!checked) {
+ return prev.filter((num) => num !== weekNum);
+ }
+ if (prev.indexOf(weekNum) === -1) {
+ prev.push(weekNum);
+ }
+ return prev;
+};
+
+export const calculateIsAllDay = (startDate, startTime, endDate, endTime) =>
+ startDate === endDate && startTime === "00:00" && endTime === "23:59";
+
+export const getInitializeEndTimeAfterChangeStartTime = (
+ startDate,
+ endDate,
+ newStartTime,
+ prevEndTime,
+) => {
+ if (
+ typeof startDate !== "string" ||
+ typeof endDate !== "string" ||
+ typeof newStartTime !== "string" ||
+ typeof prevEndTime !== "string"
+ )
+ throw Error("잘못된 파라미터 형식입니다.");
+ else {
+ return startDate === endDate && newStartTime >= prevEndTime
+ ? newStartTime
+ : prevEndTime;
+ }
+};
+
+export const validateDateTimeIsValid = (
+ startDate,
+ startTime,
+ endDate,
+ endTime,
+) => {
+ if (
+ typeof startDate !== "string" ||
+ typeof startTime !== "string" ||
+ typeof endDate !== "string" ||
+ typeof endTime !== "string"
+ )
+ throw Error("잘못된 파라미터 형식입니다.");
+ if (startDate < endDate) {
+ return true;
+ }
+
+ if (startDate === endDate) {
+ if (startTime < endTime) {
+ return true;
+ }
+
+ toast.error("시작 시간은 종료 시간보다 빨라야 합니다.");
+ return false;
+ }
+
+ toast.error("종료 날짜는 시작 날짜보다 동일하거나 빠를 수 없습니다.");
+ return false;
+};
+
+export const validateInterval = ({
+ freq,
+ interval,
+ startDate,
+ startTime,
+ endDate,
+ endTime,
+}) => {
+ if (
+ typeof freq !== "string" ||
+ (typeof interval !== "string" && typeof interval !== "number") ||
+ typeof startDate !== "string" ||
+ typeof startTime !== "string" ||
+ typeof endDate !== "string" ||
+ typeof endTime !== "string"
+ )
+ throw Error("잘못된 파라미터 형식입니다.");
+ if (
+ freq !== "NONE" &&
+ (!Number.isInteger(Number(interval)) || Number(interval) <= 0)
+ ) {
+ toast.error("반복 간격은 0보다 큰 자연수여야 합니다");
+ return false;
+ }
+ if (startDate === endDate) {
+ if (startTime < endTime) {
+ return true;
+ }
+ toast.error(
+ "반복 요일은 무조건 일정 시작 날짜에 해당하는 요일을 포함해야 합니다.",
+ );
+ return false;
+ }
+ return true;
+};
+
+export const validateByweekday = ({ freq, byweekday, startDate }) => {
+ if (
+ typeof freq !== "string" ||
+ !(byweekday instanceof Array) ||
+ typeof startDate !== "string"
+ )
+ throw Error("잘못된 파라미터 형식입니다.");
+
+ if (!freq.startsWith("WEEKLY")) {
+ return true;
+ }
+
+ if (byweekday.indexOf(new Date(startDate).getDay()) === -1) {
+ toast.error(
+ "반복 요일은 무조건 일정 시작 날짜에 해당하는 요일을 포함해야 합니다.",
+ );
+ return false;
+ }
+
+ return true;
+};
+
+export const validateUntil = ({ until, startDate, freq, interval }) => {
+ if (
+ typeof until !== "string" ||
+ typeof startDate !== "string" ||
+ typeof freq !== "string" ||
+ (typeof interval !== "string" && typeof interval !== "number")
+ )
+ throw Error("잘못된 파라미터 형식입니다.");
+
+ if (until && startDate >= until) {
+ toast.error("반복 종료 일자는 일정 시작 날짜보다 커야 합니다.");
+ return false;
+ }
+
+ if (
+ !until ||
+ until >= calculateMinUntilDateString(startDate, freq, interval)
+ ) {
+ return true;
+ }
+
+ toast.error(
+ `반복 종료 일자는 최소 ${interval}${getRecurringString(
+ freq,
+ )} 이후여야 합니다.`,
+ );
+ return false;
+};