diff --git a/__test__/__mocks__/handlers/group/calendar.js b/__test__/__mocks__/handlers/group/calendar.js new file mode 100644 index 000000000..59c5df901 --- /dev/null +++ b/__test__/__mocks__/handlers/group/calendar.js @@ -0,0 +1,175 @@ +export const getGroupScheduleSummary = (req, res, ctx) => { + const groupId = Number(req.params.group_id); + if (!groupId) { + return res( + ctx.status(400), + ctx.json({ error: "형식에 맞지 않는 데이터입니다." }), + ); + } + + if (groupId < 1) { + return res( + ctx.status(404), + ctx.json({ error: "그룹을 찾을 수 없습니다." }), + ); + } + + const startDateTime = req.url.searchParams.get("startDateTime"); + const endDateTime = new Date( + new Date(startDateTime).setHours(23, 59, 59, 999), + ).toISOString(); + // groupId가 1인 그룹은 공유된 일정이 있지만, 나버지는 공유된 일정이 없어 빈 일정이라 가정. + return res( + ctx.status(200), + ctx.json({ + accessLevel: "owner", + schedules: + groupId === 1 + ? [ + { + id: 1, + userId: 1, + startDateTime, + endDateTime, + recurrence: 0, + freq: null, + interval: null, + byweekday: null, + until: null, + isGroup: 0, + }, + { + id: 2, + userId: 1, + startDateTime, + endDateTime, + recurrence: 0, + freq: null, + interval: null, + byweekday: null, + until: null, + isGroup: 0, + }, + ] + : [], + }), + ); +}; + +export const getScheduleProposalsList = (req, res, ctx) => { + // 일단은 빈 것 + return res(ctx.status(200), ctx.json([])); +}; + +export const getGroupSchedule = (req, res, ctx) => { + const groupId = Number(req.params.group_id); + if (!groupId) { + return res( + ctx.status(400), + ctx.json({ error: "형식에 맞지 않는 데이터입니다." }), + ); + } + + if (groupId < 1) { + return res( + ctx.status(404), + ctx.json({ error: "그룹을 찾을 수 없습니다." }), + ); + } + + const startDateTime = req.url.searchParams.get("startDateTime"); + const endDateTime = new Date( + new Date(startDateTime).setHours(23, 59, 59, 999), + ).toISOString(); + return res( + ctx.status(200), + ctx.json({ + schedules: [ + { + id: 1, + userId: 1, + title: "오늘오늘", + content: "오늘 끝", + startDateTime, + endDateTime, + recurrence: 0, + freq: null, + interval: null, + byweekday: null, + until: null, + isGroup: 0, + }, + ], + }), + ); +}; + +export const getSingleGroupSchedule = (req, res, ctx) => { + const groupId = Number(req.params.group_id); + if (!groupId) { + return res( + ctx.status(400), + ctx.json({ error: "형식에 맞지 않는 데이터입니다." }), + ); + } + + if (groupId < 1) { + return res( + ctx.status(404), + ctx.json({ error: "그룹을 찾을 수 없습니다." }), + ); + } + + return res( + ctx.status(200), + ctx.json({ + id: Number(req.params.id), + userId: 1, + title: "오늘오늘", + content: "오늘 끝", + startDateTime: "2023-12-14T01:55:00.000Z", + endDateTime: "2023-12-14T05:55:00.000Z", + recurrence: 0, + freq: null, + interval: null, + byweekday: null, + until: null, + }), + ); +}; + +export const deleteGroupSchedule = (req, res, ctx) => { + const groupId = Number(req.params.group_id); + if (!groupId) { + return res( + ctx.status(400), + ctx.json({ error: "형식에 맞지 않는 데이터입니다." }), + ); + } + + if (groupId < 1) { + return res( + ctx.status(404), + ctx.json({ error: "그룹을 찾을 수 없습니다." }), + ); + } + + try { + const scheduleId = Number(req.params.id); + if (!scheduleId) + return res( + ctx.status(400), + ctx.json({ error: "지원하지 않는 형식의 데이터입니다." }), + ); + // 삭제할 일정이 무조건 id가 1이라는 가정 하에 + if (scheduleId !== 1) + return res( + ctx.status(404), + ctx.json({ error: "일정을 찾을 수 없습니다." }), + ); + return res(ctx.status(204)); + } catch (error) { + console.log(error); + return res(ctx.status(500), ctx.json({ error: "Internal Server Error" })); + } +}; diff --git a/__test__/__mocks__/handlers/group/inviteLink.js b/__test__/__mocks__/handlers/group/inviteLink.js new file mode 100644 index 000000000..f95bb0f2d --- /dev/null +++ b/__test__/__mocks__/handlers/group/inviteLink.js @@ -0,0 +1,50 @@ +const inviteCode = "123456789012"; + +export const getInviteLink = (req, res, ctx) => { + const groupId = Number(req.params.group_id); + if (!groupId) { + return res( + ctx.status(400), + ctx.json({ error: "형식에 맞지 않는 데이터입니다." }), + ); + } + + if (groupId < 1) { + return res( + ctx.status(404), + ctx.json({ error: "그룹을 찾을 수 없습니다." }), + ); + } + + try { + // 임의의 12자리 code 생성 + return res(ctx.status(200), ctx.json({ inviteCode })); + } catch (error) { + console.log(error); + return res(ctx.status(500), ctx.json({ error: "Internal Server Error" })); + } +}; +export const postInviteLink = (req, res, ctx) => { + const groupId = Number(req.params.group_id); + if (!groupId) { + return res( + ctx.status(400), + ctx.json({ error: "형식에 맞지 않는 데이터입니다." }), + ); + } + + if (groupId < 1) { + return res( + ctx.status(404), + ctx.json({ error: "그룹을 찾을 수 없습니다." }), + ); + } + + try { + // 임의의 12자리 code 생성 + return res(ctx.status(200), ctx.json({ inviteCode })); + } catch (error) { + console.log(error); + return res(ctx.status(500), ctx.json({ error: "Internal Server Error" })); + } +}; diff --git a/__test__/__mocks__/handlers/group/members.js b/__test__/__mocks__/handlers/group/members.js new file mode 100644 index 000000000..5cdd47ee7 --- /dev/null +++ b/__test__/__mocks__/handlers/group/members.js @@ -0,0 +1,101 @@ +export const getGroupMembers = (req, res, ctx) => { + try { + const groupId = Number(req.params.group_id); + if (!groupId) { + return res( + ctx.status(400), + ctx.json({ error: "형식에 맞지 않는 데이터입니다." }), + ); + } + + if (groupId < 1) { + return res( + ctx.status(404), + ctx.json({ error: "그룹을 찾을 수 없습니다." }), + ); + } + + // userId === 1 입니다. + return res( + ctx.status(200), + ctx.json([ + { + accessLevel: "viewer", + member: { + nickname: "user2", + userId: 2, + image: + "https://selody-images.s3.ap-northeast-2.amazonaws.com/profile/calender-dynamic-gradient%2B1.png", + commentCount: 0, + likeCount: 0, + joinedDate: "2024-01-15T08:06:14.000Z", + }, + }, + { + accessLevel: "viewer", + member: { + nickname: "user3", + userId: 3, + image: + "https://selody-images.s3.ap-northeast-2.amazonaws.com/profile/calender-dynamic-gradient%2B1.png", + commentCount: 0, + likeCount: 0, + joinedDate: "2024-01-15T08:07:14.000Z", + }, + }, + { + accessLevel: "viewer", + member: { + nickname: "user4", + userId: 4, + image: + "https://selody-images.s3.ap-northeast-2.amazonaws.com/profile/calender-dynamic-gradient%2B1.png", + commentCount: 0, + likeCount: 0, + joinedDate: "2024-01-15T08:06:14.000Z", + }, + }, + { + accessLevel: "viewer", + member: { + nickname: "user5", + userId: 5, + image: + "https://selody-images.s3.ap-northeast-2.amazonaws.com/profile/calender-dynamic-gradient%2B1.png", + commentCount: 0, + likeCount: 0, + joinedDate: "2024-01-15T08:07:14.000Z", + }, + }, + { + accessLevel: "viewer", + member: { + nickname: "user6", + userId: 6, + image: + "https://selody-images.s3.ap-northeast-2.amazonaws.com/profile/calender-dynamic-gradient%2B1.png", + commentCount: 0, + likeCount: 0, + joinedDate: "2024-01-15T08:07:14.000Z", + }, + }, + { + accessLevel: "owner", + member: { + nickname: "kyy", + userId: 1, + image: + "https://selody-images.s3.ap-northeast-2.amazonaws.com/profile/calender-dynamic-gradient%2B1.png", + commentCount: 0, + likeCount: 0, + joinedDate: "2024-01-15T08:05:52.000Z", + }, + }, + ]), + ); + } catch (error) { + console.log("그룹 멤버 조회 중 발생한 에러"); + console.log(error); + return res(ctx.status(500), ctx.json({ error: "Internal Server error." })); + } +}; diff --git a/__test__/__mocks__/handlers/group/proposal.js b/__test__/__mocks__/handlers/group/proposal.js new file mode 100644 index 000000000..9ab8fef53 --- /dev/null +++ b/__test__/__mocks__/handlers/group/proposal.js @@ -0,0 +1,97 @@ +export const getRecommendedProposals = (req, res, ctx) => { + try { + const groupId = Number(req.params.group_id); + + const startDateTime = req.url.searchParams.get("startDateTime"); + const endDateTime = req.url.searchParams.get("endDateTime"); + const duration = req.url.searchParams.get("duration"); + + if (!groupId || !startDateTime || !endDateTime || !duration) { + return res( + ctx.status(400), + ctx.json({ error: "형식에 맞지 않는 데이터입니다." }), + ); + } + + if (groupId < 1) { + return res( + ctx.status(404), + ctx.json({ error: "그룹을 찾을 수 없습니다." }), + ); + } + + return res( + ctx.status(200), + ctx.json({ + proposals: [ + { + startDateTime, + endDateTime, + duration: new Date(startDateTime) - new Date(endDateTime), + }, + ], + }), + ); + } catch (error) { + console.log(error); + return res(ctx.status(500), ctx.json({ error: "Internal Server Error" })); + } +}; + +// 일정 후보 하나만 올린다는 가정하에 입니다. 여러 개를 올릴 경우 auto-increment 처리로 유니크한 voteId를 할당해야 함. +export const enrollScheduleProposal = (req, res, ctx) => { + try { + const { + body: { + title, + content, + startDateTime, + endDateTime, + recurrence, + freq, + interval, + byweekday, + until, + }, + params: { group_id: groupId }, + } = req; + + if (!groupId || !title || !content) { + return res( + ctx.status(400), + ctx.json({ error: "형식에 맞지 않는 데이터입니다." }), + ); + } + + if (groupId < 1) { + return res( + ctx.status(404), + ctx.json({ error: "그룹을 찾을 수 없습니다." }), + ); + } + const votingEndDate = new Date(); + votingEndDate.setDate(votingEndDate.getDate() + 7); + return res( + ctx.status(200), + ctx.json({ + voteId: 1, + votingEndDate: votingEndDate.toUTCString(), + voteResults: [], + voteCount: 0, + groupId, + title, + content, + startDateTime, + endDateTime, + recurrence, + freq, + interval, + byweekday, + until, + }), + ); + } catch (error) { + console.log(error); + return res(ctx.status(500), ctx.json({ error: "Internal Server Error" })); + } +}; diff --git a/__test__/__mocks__/handlers/users/calendar.js b/__test__/__mocks__/handlers/user/calendar.js similarity index 100% rename from __test__/__mocks__/handlers/users/calendar.js rename to __test__/__mocks__/handlers/user/calendar.js diff --git a/__test__/__mocks__/handlers/user/group.js b/__test__/__mocks__/handlers/user/group.js new file mode 100644 index 000000000..dcee20eff --- /dev/null +++ b/__test__/__mocks__/handlers/user/group.js @@ -0,0 +1,36 @@ +export const getUserGroup = (req, res, ctx) => { + try { + return res( + ctx.status(200), + ctx.json([ + { + groupId: 1, + name: "내 그룹 1", + description: "test-description", + member: 1, + image: + "https://selody-images.s3.ap-northeast-2.amazonaws.com/group/group.png", + }, + { + groupId: 2, + name: "내 그룹 2", + description: "test-dㅁㄴㄱ호데;ㅗㅎㄱescription", + member: 2, + image: + "https://selody-images.s3.ap-northeast-2.amazonaws.com/group/group.png", + }, + { + groupId: 3, + name: "내 그룹 3", + description: "이게 내 세 번째", + member: 2, + image: + "https://selody-images.s3.ap-northeast-2.amazonaws.com/group/group.png", + }, + ]), + ); + } catch (error) { + console.log(error); + return res(ctx.status(500), ctx.json({ error: "Internal Server Error" })); + } +}; diff --git a/__test__/__mocks__/server.js b/__test__/__mocks__/server.js index 525ef7482..12cc6a279 100644 --- a/__test__/__mocks__/server.js +++ b/__test__/__mocks__/server.js @@ -1,6 +1,19 @@ import { rest } from "msw"; import { setupServer } from "msw/node"; +import { + deleteGroupSchedule, + getGroupSchedule, + getGroupScheduleSummary, + getScheduleProposalsList, + // getSingleGroupSchedule, +} from "./handlers/group/calendar"; +import { getInviteLink, postInviteLink } from "./handlers/group/inviteLink"; +import { getGroupMembers } from "./handlers/group/members"; +import { + enrollScheduleProposal, + getRecommendedProposals, +} from "./handlers/group/proposal"; import { deletePersonalSchedule, getSingleUserSchedule, @@ -8,7 +21,8 @@ import { getUserPersonalScheduleSummary, postPersonalSchedule, putPersonalSchedule, -} from "./handlers/users/calendar"; +} from "./handlers/user/calendar"; +import { getUserGroup } from "./handlers/user/group"; const BASE_URL = "http://localhost/back"; @@ -22,6 +36,37 @@ export const handlers = [ rest.post(`${BASE_URL}/api/user/calendar`, postPersonalSchedule), rest.put(`${BASE_URL}/api/user/calendar/:id`, putPersonalSchedule), rest.delete(`${BASE_URL}/api/user/calendar/:id`, deletePersonalSchedule), + + rest.get(`${BASE_URL}/api/user/group`, getUserGroup), + + rest.get( + `${BASE_URL}/api/group/:group_id/proposal/list`, + getScheduleProposalsList, + ), + + rest.get(`${BASE_URL}/api/group/:group_id/calendar`, getGroupSchedule), + rest.get( + `${BASE_URL}/api/group/:group_id/calendar/summary`, + getGroupScheduleSummary, + ), + rest.delete( + `${BASE_URL}/api/group/:group_id/calendar/api/:schedule_id`, + deleteGroupSchedule, + ), + + rest.get(`${BASE_URL}/api/group/:group_id/members`, getGroupMembers), + // rest.get( + // `${BASE_URL}/api/group/:group_id/calendar/api/:schedule_id`, + // getSingleGroupSchedule, + // ), + rest.get(`${BASE_URL}/api/group/:group_id/join/:invite-link`, getInviteLink), + rest.get(`${BASE_URL}/api/group/:group_id/join/:invite-link`, postInviteLink), + + rest.get( + `${BASE_URL}/api/group/:group_id/proposals`, + getRecommendedProposals, + ), + rest.post(`${BASE_URL}/api/group/:group_id/proposal`, enrollScheduleProposal), ]; export const server = setupServer(...handlers); diff --git a/__test__/pagesTest/PersonalSchedulePage.test.jsx b/__test__/pagesTest/PersonalSchedulePage.test.jsx index 9f2ffbda4..017af7f5a 100644 --- a/__test__/pagesTest/PersonalSchedulePage.test.jsx +++ b/__test__/pagesTest/PersonalSchedulePage.test.jsx @@ -7,33 +7,13 @@ import ReactDOM from "react-dom"; import { userEvent } from "@storybook/testing-library"; import { screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; - import ScheduleModal from "@/components/Common/ScheduleModal/ScheduleModal.jsx"; import { SCHEDULE_MODAL_TYPE } from "@/constants/uiConstants"; -import PersonalSchedulePage from "@/pages/PersonalSchedulePage/PersonalSchedulePage.jsx"; +import PersonalSchedulePage from "@/pages/PersonalSchedulePage.jsx"; import lightTheme from "@/styles/theme.js"; import { render } from "../../jest.setup.js"; -// jest.mock("@fullcalendar/react", () => () => ( -//
-// )); -// jest.mock("@fullcalendar/timegrid", () => ({})); -// jest.mock("@fullcalendar/daygrid", () => ({})); -// jest.mock("@fullcalendar/interaction", () => ({})); - -jest.mock( - "../../src/components/Common/CalendarContainer/CustomCalendar/CustomCalendar.jsx", - () => { - const { forwardRef } = jest.requireActual("react"); - return { - __esModule: true, - default: forwardRef(() =>
), - }; - }, -); - const TITLE_TEXT = "일정 1"; const CONTENT_TEXT = "일정 상세 정보"; @@ -46,6 +26,9 @@ const getInitialScheduleState = ({ recurrence, isAllDay, isMine }) => { } return { schedule: { + calendarSchedules: [], + currentGroupScheduleId: null, + scheduleProposals: [], todaySchedules: [ { id: 0, @@ -57,7 +40,6 @@ const getInitialScheduleState = ({ recurrence, isAllDay, isMine }) => { recurrence, }, ], - calendarSchedules: [], schedulesForTheWeek: [], overlappedScheduleInfo: { title: "", @@ -605,12 +587,12 @@ describe("ScheduleModal in PersonalSchedulePage", () => { }); describe("mutate schedule", () => { it("POST new all day schedule", async () => { - render(, { + const { unmount } = render(, { preloadedState: { auth: { user: { userId: 1 } } }, }); // open ScheduleModal as a create mode - userEvent.click(screen.getByRole("button", { name: "일정 추가" })); + await userEvent.click(screen.getByRole("button", { name: "일정 추가" })); // action const titleInput = screen.getByPlaceholderText("일정 제목"); @@ -623,16 +605,17 @@ describe("ScheduleModal in PersonalSchedulePage", () => { userEvent.type(contentTextarea, CONTENT_TEXT); userEvent.click(allDayCheckbox); await userEvent.click(screen.getByRole("button", { name: "저장하기" })); - // assertion expect( await screen.findByRole("heading", { name: TITLE_TEXT, }), ).toBeInTheDocument(); + + unmount(); }); it("PUT all day schedule after changing title", async () => { - render(, { + const { unmount } = render(, { preloadedState: { auth: { user: { userId: 1 } } }, }); @@ -661,10 +644,12 @@ describe("ScheduleModal in PersonalSchedulePage", () => { name: "오늘오늘", }), ).toBeNull(); + + unmount(); }); it("DELETE current all day schedule", async () => { - render(, { + const { unmount } = render(, { preloadedState: { auth: { user: { userId: 1 } } }, }); @@ -690,6 +675,8 @@ describe("ScheduleModal in PersonalSchedulePage", () => { }); expect(scheduleAddButton).toBeInTheDocument(); expect(scheduleItemHeadingToBeDeleted).toBeNull(); + + unmount(); }); }); }); diff --git a/__test__/pagesTest/SharedSchedulePage.test.jsx b/__test__/pagesTest/SharedSchedulePage.test.jsx new file mode 100644 index 000000000..4f0271801 --- /dev/null +++ b/__test__/pagesTest/SharedSchedulePage.test.jsx @@ -0,0 +1,496 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { userEvent } from "@storybook/testing-library"; + +import SharedSchedulePage from "@/pages/SharedSchedulePage.jsx"; +import lightTheme from "@/styles/theme.js"; +import { getTimeString } from "@/utils/calendarUtils"; + +import { render, screen, waitFor } from "../../jest.setup"; + +const startDate = new Date(); +const endDate = new Date(); +endDate.setMonth(endDate.getMonth() + 1); +const expectedTimeString = getTimeString( + startDate.toUTCString(), + endDate.toUTCString(), +); +const calendarScheduleSelector = "a.fc-event"; +const EXTRA_MEMBER_DROPDOWN_COUNT = 1; +const WEEK_STR = { + 0: "일", + 1: "월", + 2: "화", + 3: "수", + 4: "목", + 5: "금", + 6: "토", +}; + +const getDatePickerString = (obj) => { + const year = obj.getFullYear(); + const month = obj.getMonth() + 1; + const date = obj.getDate(); + + return `${year}년 ${month < 10 ? 0 : ""}${month}월 ${ + date < 10 ? 0 : "" + }${date}일`; +}; + +describe("SharedSchedulePage without modal", () => { + it("initially render same component as PersonalPage", () => { + render(, { + preloadedState: { auth: { user: { userId: 1 } } }, + }); + + const today = new Date(); + expect( + screen.getByText(`${today.getFullYear()}년 ${today.getMonth() + 1}월`), + ).toBeInTheDocument(); + + // title 렌더링 + expect(screen.getByRole("button", { name: "월별" })).toHaveStyle({ + color: lightTheme.colors.text_01, + }); + expect(screen.getByRole("button", { name: "리스트" })).toHaveStyle({ + color: lightTheme.colors.disabled_text, + }); + expect( + screen.getByRole("option", { + name: `${new Date().getFullYear()}년 ${new Date().getMonth() + 1}월`, + }), + ).toBeInTheDocument(); + + // 달력 렌더링 + expect(screen.getByTestId("calendar-container")).toBeInTheDocument(); + + // 활성화된 tab 확인 + expect(screen.getByRole("button", { name: "일정 후보" })).toHaveStyle({ + backgroundColor: lightTheme.colors.primary, + color: lightTheme.colors.white, + }); + expect( + screen.getByRole("heading", { name: "일정 후보(최대 5개)" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "후보 선택" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "후보 추가" }), + ).toBeInTheDocument(); + // 빈 경우에 버튼. 추후 비동기작업으로 더미 후보 추가 예정 + expect( + screen.getByRole("button", { + name: "공유한 사용자들에게 일정 후보를 먼저 제안해보세요!", + }), + ).toBeInTheDocument(); + }); + it("initially render components about group", async () => { + const { unmount, container } = render(, { + preloadedState: { auth: { user: { userId: 1 } } }, + }); + + // GroupMenu + const groupMenu = await screen.findByRole("menu"); + expect(groupMenu).toBeInTheDocument(); + // div.groupMembers + expect( + (await screen.findByTestId("groupMemberAvatar-owner")).childElementCount, + ).toBe(2); + const memberAvatars = screen.getAllByTestId("groupMemberAvatar-member"); + expect(memberAvatars).toHaveLength(4); // 5명 - owner 1명 + memberAvatars.forEach((memberAvatar) => { + expect(memberAvatar.childElementCount).toBe(1); + }); + + // ExtraGroupMembers: 멤버가 6명임 + expect( + screen.getByTestId("ExtraGroupMember-toggleButton"), + ).toBeInTheDocument(); + + // div.inviteButton: 내가 만든 그룹이므로 + expect(screen.getByRole("button", { name: "사용자 초대" })).toBeEnabled(); + + // GroupSelect + expect(screen.getAllByRole("combobox")[1]).toHaveTextContent("내 그룹 1"); + + // calendarSchedules + expect(container.querySelectorAll(calendarScheduleSelector).length).toBe(2); + + // proposal list(미완): 일정 후보 수정에서 구현 예정 + + unmount(); + }); + it("toggle ExtraGroupMembers", async () => { + const { unmount } = render(, { + preloadedState: { auth: { user: { userId: 1 } } }, + }); + + const extraGroupMemberButton = await screen.findByTestId( + "ExtraGroupMember-toggleButton", + ); + + expect(extraGroupMemberButton).toBeInTheDocument(); + + userEvent.click(extraGroupMemberButton); + + expect( + screen.getByTestId("ExtraGroupMember-dropdown").childElementCount, + ).toBe(1 + EXTRA_MEMBER_DROPDOWN_COUNT); + + userEvent.click(extraGroupMemberButton); + + expect(screen.queryByTestId("ExtraGroupMember-dropdown")).toBeNull(); + + unmount(); + }); + it("toggle GroupInviteLink with buttons", async () => { + const { unmount } = render(, { + preloadedState: { auth: { user: { userId: 1 } } }, + }); + + const inviteButton = await screen.findByRole("button", { + name: "사용자 초대", + }); + + userEvent.click(inviteButton); + + await waitFor( + () => expect(screen.getByRole("button", { name: "복사" })).toBeEnabled(), + { timeout: 5000 }, + ); + + userEvent.click(inviteButton); + + expect(screen.queryByRole("button", { name: "복사" })).toBeNull(); + + unmount(); + }); + it("GroupSelect: change selected group", async () => { + const { unmount, container } = render(, { + preloadedState: { auth: { user: { userId: 1 } } }, + }); + // wait for rendering GroupSelect + await screen.findByRole("menu"); + + const GroupSelect = screen.getByRole("button", { name: "내 그룹 1" }); + + userEvent.click(GroupSelect); + + const initialGroupOption = screen.getByTestId(1); + const GroupOptionToChange = screen.getByTestId(2); + + expect(initialGroupOption).toBeInTheDocument(); + expect(initialGroupOption).toHaveStyle({ + backgroundColor: lightTheme.colors.primary, + color: lightTheme.colors.white, + }); + + expect(GroupOptionToChange).toBeInTheDocument(); + + userEvent.click(GroupOptionToChange); + + await waitFor(() => { + expect(container.querySelectorAll(calendarScheduleSelector).length).toBe( + 0, + ); + }); + + unmount(); + }); + it("Mutate group schedule proposal", () => {}); +}); + +describe("ScheduleProposalModal in SharedSchedulePage", () => { + beforeAll(() => { + ReactDOM.createPortal = jest.fn((element) => { + return element; + }); + window.scrollTo = jest.fn(); + }); + it("trigger opening ScheduleProposalModal", () => { + const { unmount } = render(, { + preloadedState: { auth: { user: { userId: 1 } } }, + }); + + userEvent.click(screen.getByRole("button", { name: "후보 추가" })); + + expect(screen.getByTestId("ScheduleProposalModal")).toBeInTheDocument(); + + // x button + expect(screen.getByTestId("modal-closeButton")).toBeInTheDocument(); + + // title + expect( + screen.getByRole("heading", { name: "일정 후보 등록" }), + ).toBeInTheDocument(); + // input, textarea + expect(screen.getByPlaceholderText("일정 후보 제목")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("상세 내용")).toBeInTheDocument(); + + // 일정 추천 + expect( + screen.getByRole("heading", { name: "일정 추천" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "일정 검색 범위" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "일정 최소 구간" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "1시간" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "추천받기" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "직접 만들기" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "등록하기" })).toBeDisabled(); + + unmount(); + }); + it("toggle proposalEditForm in ScheduleProposalModal", () => { + const { unmount } = render(, { + preloadedState: { + auth: { user: { userId: 1 } }, + }, + }); + + userEvent.click(screen.getByRole("button", { name: "후보 추가" })); + + userEvent.click(screen.getByRole("button", { name: "직접 만들기" })); + + // x button + expect(screen.queryByTestId("modal-closeButton")).toBeNull(); + + // title + expect( + screen.getByRole("heading", { name: "일정 후보 수정" }), + ).toBeInTheDocument(); + // input, textarea + expect(screen.getByPlaceholderText("일정 후보 제목")).toBeDisabled(); + expect(screen.getByPlaceholderText("상세 내용")).toBeDisabled(); + + // allday checkbox + expect(screen.getByLabelText("하루 종일")).not.toBeChecked(); + + // repeat + expect( + screen.getByRole("heading", { name: "반복 여부" }), + ).toBeInTheDocument(); + expect(screen.getByText("반복 안함")).toBeInTheDocument(); + + // footer buttons + const backButton = screen.getByTestId("editProposalForm-backButton"); + expect(backButton).toBeEnabled(); + expect(screen.getByRole("button", { name: "저장하기" })).toBeDisabled(); + + userEvent.click(backButton); + + // title + expect( + screen.getByRole("heading", { name: "일정 후보 등록" }), + ).toBeInTheDocument(); + // input, textarea + expect(screen.getByPlaceholderText("일정 후보 제목")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("상세 내용")).toBeInTheDocument(); + + // 일정 추천 + expect( + screen.getByRole("heading", { name: "일정 추천" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "일정 검색 범위" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "일정 최소 구간" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "1시간" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "추천받기" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "직접 만들기" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "등록하기" })).toBeDisabled(); + + unmount(); + }); + it("get recommended proposals", async () => { + const { unmount } = render(, { + preloadedState: { + auth: { user: { userId: 1 } }, + schedule: { + currentGroupScheduleId: null, + calendarSchedules: [], + overlappedScheduleInfo: { title: "", schedules: [] }, + scheduleProposals: [], + recommendedScheduleProposals: [], + }, + }, + }); + + // set schedule.currentGroupScheduleId to 1 + await screen.findByRole("button", { name: /내 그룹 1/i }); + + userEvent.click(screen.getByRole("button", { name: "후보 추가" })); + + userEvent.click( + screen.getAllByRole("button", { + name: getDatePickerString(new Date()), + })[1], + ); + + // endDate를 한 달 후로 변경하여 추천 받기 버튼 활성화 + userEvent.click(screen.getByLabelText("Next Month")); + userEvent.click( + screen.getByLabelText( + `Choose ${endDate.getFullYear()}년 ${ + endDate.getMonth() + 1 + }월 ${endDate.getDate()}일 ${WEEK_STR[endDate.getDay()]}요일`, + ), + ); + userEvent.click(screen.getByRole("button", { name: "확인" })); + + expect(screen.getByText(getDatePickerString(endDate))).toBeInTheDocument(); + + // 추천 받기 + userEvent.click(screen.getByRole("button", { name: "추천받기" })); + + expect( + await screen.findByText(expectedTimeString, { timeout: 5000 }), + ).toBeInTheDocument(); + expect(screen.getByText("반복")).toHaveStyle({ + backgroundColor: lightTheme.colors.btn_02, + color: lightTheme.colors.white, + }); + expect(screen.getByRole("button", { name: "수정하기" })).toBeEnabled(); + + unmount(); + }); + it("add proposal myself", () => { + const { unmount } = render(, { + preloadedState: { + auth: { user: { userId: 1 } }, + }, + }); + + userEvent.click(screen.getByRole("button", { name: "후보 추가" })); + + // 하루 종일 + userEvent.click(screen.getByRole("button", { name: "직접 만들기" })); + userEvent.click(screen.getByLabelText("하루 종일")); + userEvent.click(screen.getByRole("button", { name: "저장하기" })); + expect( + screen.getByText( + `${new Date().getMonth() + 1}월 ${new Date().getDate()}일 하루 종일`, + ), + ).toBeInTheDocument(); + expect(screen.getByText("반복")).toHaveStyle({ + backgroundColor: lightTheme.colors.btn_02, + color: lightTheme.colors.white, + }); + + // 반복 + 하루 종일 + userEvent.click(screen.getByRole("button", { name: "직접 만들기" })); + userEvent.click(screen.getByLabelText("하루 종일")); + userEvent.click(screen.getByRole("button", { name: /반복 안함/i })); + userEvent.click(screen.getByRole("button", { name: "매일" })); + userEvent.click(screen.getByRole("button", { name: "저장하기" })); + expect( + screen.getAllByText( + `${new Date().getMonth() + 1}월 ${new Date().getDate()}일 하루 종일`, + ), + ).toHaveLength(2); + expect(screen.getAllByText("반복")[1]).toHaveStyle({ + backgroundColor: lightTheme.colors.primary, + color: lightTheme.colors.white, + }); + + unmount(); + }); + it("edit proposal", () => { + const { unmount } = render(, { + preloadedState: { + auth: { user: { userId: 1 } }, + }, + }); + + userEvent.click(screen.getByRole("button", { name: "후보 추가" })); + + // add one myself + userEvent.click(screen.getByRole("button", { name: "직접 만들기" })); + userEvent.click(screen.getByLabelText("하루 종일")); + userEvent.click(screen.getByRole("button", { name: "저장하기" })); + expect( + screen.getByText( + `${new Date().getMonth() + 1}월 ${new Date().getDate()}일 하루 종일`, + ), + ).toBeInTheDocument(); + + // edit it + userEvent.click(screen.getByRole("button", { name: "수정하기" })); + userEvent.click(screen.getByLabelText("하루 종일")); + userEvent.click(screen.getByRole("button", { name: "저장하기" })); + + // result + expect( + screen.queryByText( + `${new Date().getMonth() + 1}월 ${new Date().getDate()}일 하루 종일`, + ), + ).toBeNull(); + expect( + screen.getByText( + `${ + new Date().getMonth() + 1 + }월 ${new Date().getDate()}일 00:00 ~ 23:59`, + ), + ).toBeInTheDocument(); + + unmount(); + }); + it("POST: enroll proposals made by myself", async () => { + const { unmount } = render(, { + preloadedState: { + auth: { user: { userId: 1 } }, + schedule: { + currentGroupScheduleId: null, + calendarSchedules: [], + overlappedScheduleInfo: { title: "", schedules: [] }, + scheduleProposals: [], + recommendedScheduleProposals: [], + }, + }, + }); + + // set schedule.currentGroupScheduleId to 1 + await screen.findByRole("button", { name: /내 그룹 1/i }); + + userEvent.click(screen.getByRole("button", { name: "후보 추가" })); + + // add one myself + userEvent.click(screen.getByRole("button", { name: "직접 만들기" })); + userEvent.click(screen.getByLabelText("하루 종일")); + userEvent.click(screen.getByRole("button", { name: "저장하기" })); + expect( + screen.getByText( + `${new Date().getMonth() + 1}월 ${new Date().getDate()}일 하루 종일`, + ), + ).toBeInTheDocument(); + + // type body + userEvent.type( + screen.getByPlaceholderText("일정 후보 제목"), + "일정 후보 이름 1", + ); + userEvent.type( + screen.getByPlaceholderText("상세 내용"), + "일정 후보 이름 설명", + ); + screen + .getAllByLabelText(/checkbox-recommendedProposal/i) + .forEach((el) => userEvent.click(el)); + + // submit + expect(screen.getByRole("button", { name: "등록하기" })).toBeEnabled(); + await userEvent.click(screen.getByRole("button", { name: "등록하기" })); + + // 이후 다른 브랜치에서 레이아웃 작업할 예정 + expect(await screen.findAllByText("ScheduleVoteItem")).toHaveLength(1); + + unmount(); + }); +}); diff --git a/jest.config.cjs b/jest.config.cjs index d8a29cfcb..88cac0cb8 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,5 +1,8 @@ module.exports = { testEnvironment: "jsdom", + testEnvironmentOptions: { + customExportConditions: [], // don't load "browser" field + }, transform: { "^.+\\.jsx?$": "babel-jest", "^.+\\.svg$": "jest-transformer-svg", @@ -19,4 +22,5 @@ module.exports = { "^@utils/(.*)$": "/src/utils/$1", "\\.(css|less|scss|sss|styl)$": "/node_modules/jest-css-modules", }, + testTimeout: 20000, }; diff --git a/src/App.jsx b/src/App.jsx index 1b5287e32..36b0dba6c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,11 +10,11 @@ import { LoginPage, SignUpPage, PersonalSchedulePage, - GroupSchedulePage, SettingPage, CommunityPage, GroupPage, MyPage, + SharedSchedulePage, } from "@/pages"; import { getCurrentUser } from "./features/auth/auth-service.js"; @@ -28,7 +28,7 @@ const router = createBrowserRouter([ errorElement: , children: [ { path: "personal", element: }, - { path: "share", element: }, + { path: "share", element: }, { path: "community", element: }, { path: "setting", element: }, { path: "group/:id", element: }, diff --git a/src/assets/icon/ic-back-arrow.svg b/src/assets/icon/ic-back-arrow.svg new file mode 100644 index 000000000..e236aa64b --- /dev/null +++ b/src/assets/icon/ic-back-arrow.svg @@ -0,0 +1,16 @@ + + + + diff --git a/src/assets/icon/ic-dotted-calendar.svg b/src/assets/icon/ic-dotted-calendar.svg new file mode 100644 index 000000000..11169d7d7 --- /dev/null +++ b/src/assets/icon/ic-dotted-calendar.svg @@ -0,0 +1,14 @@ + + + diff --git a/src/assets/icon/ic-extraMembers-dropdown.svg b/src/assets/icon/ic-extraMembers-dropdown.svg new file mode 100644 index 000000000..f95286618 --- /dev/null +++ b/src/assets/icon/ic-extraMembers-dropdown.svg @@ -0,0 +1,26 @@ + + + + + + + diff --git a/src/assets/icon/ic-white-plus.svg b/src/assets/icon/ic-white-plus.svg new file mode 100644 index 000000000..a59351a35 --- /dev/null +++ b/src/assets/icon/ic-white-plus.svg @@ -0,0 +1,10 @@ + + + + diff --git a/src/components/Common/Modal/EmptyUserGroupNotificationModal/EmptyUserGroupNotificationModal.jsx b/src/components/Common/Modal/EmptyUserGroupNotificationModal/EmptyUserGroupNotificationModal.jsx new file mode 100644 index 000000000..2b5fe4d46 --- /dev/null +++ b/src/components/Common/Modal/EmptyUserGroupNotificationModal/EmptyUserGroupNotificationModal.jsx @@ -0,0 +1,29 @@ +import React from "react"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +import BaseModal from "@/components/Common/Modal/BaseModal"; +import { closeModal } from "@/features/ui/ui-slice"; + +import { NotificationModalWrapperDiv } from "./EmptyUserGroupNotificationModal.styles"; + +const EmptyUserGroupNotificationModal = () => { + const dispatch = useDispatch(); + const navitage = useNavigate(); + return ( + dispatch(closeModal())}> + +

가입된 그룹이 없습니다.

+

+ '그룹 신청하기' 버튼을 통해 + 그룹을 만들어보세요. +

+ +
+
+ ); +}; + +export default EmptyUserGroupNotificationModal; diff --git a/src/components/Common/Modal/EmptyUserGroupNotificationModal/EmptyUserGroupNotificationModal.styles.js b/src/components/Common/Modal/EmptyUserGroupNotificationModal/EmptyUserGroupNotificationModal.styles.js new file mode 100644 index 000000000..58cf60ecf --- /dev/null +++ b/src/components/Common/Modal/EmptyUserGroupNotificationModal/EmptyUserGroupNotificationModal.styles.js @@ -0,0 +1,49 @@ +import styled from "styled-components"; + +export const NotificationModalWrapperDiv = styled.div` + margin: 19px 23px 1px; + gap: 48px; + font-family: "Inter", sans-serif; + + &, + & > p { + display: flex; + flex-direction: column; + align-items: center; + } + + & > h2 { + font-size: 18px; + font-weight: ${({ + theme: { + typography: { weight }, + }, + }) => weight.semibold}; + } + + & > p > span { + font-size: ${({ + theme: { + typography: { size }, + }, + }) => size.s2}; + font-weight: ${({ + theme: { + typography: { weight }, + }, + }) => weight.medium}; + color: ${({ theme: { colors } }) => colors.disabled_text}; + } + + & > button { + border-radius: 5px; + background-color: ${({ theme: { colors } }) => colors.btn_01}; + min-width: 323px; + height: 48px; + line-height: 48px; + text-align: center; + font-size: 15px; + color: ${({ theme: { colors } }) => colors.white}; + cursor: pointer; + } +`; diff --git a/src/components/Common/Modal/FormModal/FormModal.jsx b/src/components/Common/Modal/FormModal/FormModal.jsx index 504ef8a7f..1a609d531 100644 --- a/src/components/Common/Modal/FormModal/FormModal.jsx +++ b/src/components/Common/Modal/FormModal/FormModal.jsx @@ -15,16 +15,22 @@ const Backdrop = ({ onClick }) => ( ); -const Modal = ({ children, onCloseButtonClick }) => ( +const Modal = ({ children, onCloseButtonClick, isCloseButtonHidden }) => ( - - - + {isCloseButtonHidden || ( + + + + )} {children} ); -const FormModal = ({ children, isEmpty }) => { +const FormModal = ({ children, isEmpty, isCloseButtonHidden = false }) => { const [isFormCancelWarningModalOn, setIsFormCancelWarningModalOn] = useState(false); const dispatch = useDispatch(); @@ -46,7 +52,12 @@ const FormModal = ({ children, isEmpty }) => { document.getElementById("backdrop"), )} {ReactDOM.createPortal( - {children}, + + {children} + , document.getElementById("modal"), )} {isFormCancelWarningModalOn && ( diff --git a/src/components/Common/ScheduleModal.Shared.styles.js b/src/components/Common/ScheduleModal.Shared.styles.js new file mode 100644 index 000000000..2d009dce2 --- /dev/null +++ b/src/components/Common/ScheduleModal.Shared.styles.js @@ -0,0 +1,157 @@ +import styled from "styled-components"; + +export const ScheduleModalLayoutDiv = styled.div` + width: 550px; + display: flex; + flex-direction: column; + font-family: "Inter", sans-serif; + font-size: ${({ theme }) => theme.typography.size.s3}; + + & > h2 { + margin-bottom: 36px; + font-weight: ${({ + theme: { + typography: { weight }, + }, + }) => weight.semibold}; + font-size: ${({ + theme: { + typography: { size }, + }, + }) => size.m1}; + color: ${({ theme: { colors } }) => colors.text_01}; + } + + & input, + & textarea { + &:focus { + outline: none; + } + color: ${({ theme }) => theme.colors.text_03}; + &, + &::placeholder { + font-family: "Inter", sans-serif; + font-weight: ${({ theme }) => theme.typography.weight.medium}; + } + &::placeholder { + color: ${({ theme }) => theme.colors.disabled_text}; + } + } +`; + +export const TitleInput = styled.input` + all: unset; + width: 100%; + height: 35px; + margin-bottom: 24px; + border-bottom: 1px solid ${({ theme }) => theme.colors.text_01}; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +`; + +export const DetailTextarea = styled.textarea` + all: unset; + resize: none; + width: 100%; + height: 109px; + background-color: ${({ theme }) => theme.colors.bg_01}; + display: block; + font-size: 16px; + padding: 14px; + margin-bottom: ${({ theme }) => theme.spacing.padding.medium}px; + + &::placeholder { + color: ${({ theme }) => theme.colors.disabled_text}; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +`; + +export const LabelH3 = styled.h3` + color: ${({ theme: { colors } }) => colors.text_01}; + font-size: ${({ + theme: { + typography: { size }, + }, + }) => size.s2}; + line-height: 17px; + font-weight: ${({ + theme: { + typography: { weight }, + }, + }) => weight.medium}; + margin-bottom: 12px; +`; + +export const LabelH4 = styled.h4` + color: ${({ theme: { colors } }) => colors.text_01}; + font-size: ${({ + theme: { + typography: { size }, + }, + }) => size.s1}; + line-height: 17px; + font-weight: ${({ + theme: { + typography: { weight }, + }, + }) => weight.medium}; + margin-bottom: 12px; +`; + +// date and time +export const DateContainerDiv = styled.div` + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +`; +export const DateDiv = styled.div` + display: flex; + gap: 15px; + width: 45%; + + &:last-child { + justify-content: flex-end; + } +`; + +export const SubmitButton = styled.button` + display: block; + cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; + background-color: ${({ disabled, theme: { colors } }) => + disabled ? colors.btn_02 : colors.btn_01}; + + &:not(:disabled) { + &:hover { + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); + } + &:active { + box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.25); + } + } + + border-radius: 5px; + color: white; + width: 132px; + height: 40px; + text-align: center; + font-size: ${({ + theme: { + typography: { size }, + }, + }) => size.s2}; + font-weight: ${({ + theme: { + typography: { weight }, + }, + }) => weight.semibold}; + transition: box-shadow 0.3s; +`; diff --git a/src/components/Common/ScheduleModal/DateAndTime.jsx b/src/components/Common/ScheduleModal/DateAndTime.jsx index e184d031e..6285d2567 100644 --- a/src/components/Common/ScheduleModal/DateAndTime.jsx +++ b/src/components/Common/ScheduleModal/DateAndTime.jsx @@ -3,13 +3,14 @@ import React, { useState } from "react"; import moment from "moment"; import PropTypes from "prop-types"; +import DatePicker from "./DatePicker"; +import TimePicker from "./TimePicker"; import { - DateInput, - DateDiv, + LabelH3, DateContainerDiv, - InputLabel, -} from "./ScheduleModal.styles"; -import TimePicker from "./TimePicker"; + DateDiv, + LabelH4, +} from "../ScheduleModal.Shared.styles"; const TIME_PICKER_TYPE = { START: "start", @@ -18,6 +19,7 @@ const TIME_PICKER_TYPE = { }; const DateAndTime = ({ + isProposal = false, startDate, startTime, endDate, @@ -34,15 +36,15 @@ const DateAndTime = ({ return ( <> - 날짜 및 시간 + {isProposal ? "일정 추천" : "날짜 및 시간"} + {isProposal && 일정 검색 범위} - onDateChange(date, "startDate")} /> ~ - onDateChange(date, "endDate")} /> { - const minStartDate = moment( - new Date(new Date().setMonth(new Date().getMonth() - 6)), - ).format("YYYY-MM-DD"); - const [openedTimePicker, setOpenedTimePicker] = useState( - TIME_PICKER_TYPE.NONE, - ); - - return ( - <> - 날짜 및 시간 - - - onDateChange(date, "startDate")} - /> - onTimeChange(value, "startTime")} - isOpen={openedTimePicker === TIME_PICKER_TYPE.START} - onOpen={() => setOpenedTimePicker(TIME_PICKER_TYPE.START)} - onClose={() => setOpenedTimePicker(TIME_PICKER_TYPE.NONE)} - /> - - ~ - - onDateChange(date, "endDate")} - /> - onTimeChange(value, "endTime")} - isOpen={openedTimePicker === TIME_PICKER_TYPE.END} - onOpen={() => setOpenedTimePicker(TIME_PICKER_TYPE.END)} - onClose={() => setOpenedTimePicker(TIME_PICKER_TYPE.NONE)} - isModalPositionTopLeft={false} - /> - - - - ); -}; - -DateAndTime.propTypes = { - startDate: PropTypes.string.isRequired, - startTime: PropTypes.string.isRequired, - endDate: PropTypes.string.isRequired, - endTime: PropTypes.string.isRequired, - onDateChange: PropTypes.func.isRequired, - onTimeChange: PropTypes.func.isRequired, -}; - -export default DateAndTime; diff --git a/src/components/Common/ScheduleModal/DateAndTime/DateAndTime.styles.js b/src/components/Common/ScheduleModal/DateAndTime/DateAndTime.styles.js deleted file mode 100644 index 1b20b2bf5..000000000 --- a/src/components/Common/ScheduleModal/DateAndTime/DateAndTime.styles.js +++ /dev/null @@ -1,19 +0,0 @@ -import styled from "styled-components"; - -// date and time -export const DateContainerDiv = styled.div` - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -`; -export const DateDiv = styled.div` - display: flex; - gap: 15px; - width: 45%; - - &:last-child { - justify-content: flex-end; - } -`; diff --git a/src/components/Common/ScheduleModal/Repeat.jsx b/src/components/Common/ScheduleModal/Repeat.jsx deleted file mode 100644 index 1bc449c7d..000000000 --- a/src/components/Common/ScheduleModal/Repeat.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; - -import PropTypes from "prop-types"; - -import { DateInput, InputLabel, StyledSelect } from "./ScheduleModal.styles"; - -const Repeat = ({ freq, until, minUntil, onFreqChange, onUntilChange }) => { - return ( -
-
- 반복 여부 - - - - - - - - - - - -
- {freq !== "NONE" && ( - <> -
- 반복 종료 - - - - -
- {until !== "" && ( -
- 반복 종료 날짜 - -
- )} - - )} -
- ); -}; - -Repeat.propTypes = { - freq: PropTypes.oneOf([ - "NONE", - "DAILY", - "DAILY_N", - "WEEKLY", - "WEEKLY_N", - "MONTHLY", - "MONTHLY_N", - "YEARLY", - "YEARLY_N", - ]).isRequired, - until: PropTypes.string.isRequired, - minUntil: PropTypes.string.isRequired, - onFreqChange: PropTypes.func.isRequired, - onUntilChange: PropTypes.func.isRequired, -}; - -export default Repeat; diff --git a/src/components/Common/ScheduleModal/Repeat/Repeat.jsx b/src/components/Common/ScheduleModal/Repeat/Repeat.jsx index 53553a526..2aa657b6e 100644 --- a/src/components/Common/ScheduleModal/Repeat/Repeat.jsx +++ b/src/components/Common/ScheduleModal/Repeat/Repeat.jsx @@ -2,9 +2,9 @@ import React from "react"; import PropTypes from "prop-types"; +import { LabelH3 } from "../../ScheduleModal.Shared.styles"; import CustomSelect from "../CustomSelect/CustomSelect"; import DatePicker from "../DatePicker"; -import { LabelH3 } from "../ScheduleModal.styles"; const FREQ_OPTIONS = [ { value: "NONE", text: "반복 안함" }, diff --git a/src/components/Common/ScheduleModal/RepeatDetail.jsx b/src/components/Common/ScheduleModal/RepeatDetail.jsx deleted file mode 100644 index 4cb1c3b09..000000000 --- a/src/components/Common/ScheduleModal/RepeatDetail.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from "react"; - -import PropTypes from "prop-types"; - -import { ByweekdayPickerDiv } from "./ScheduleModal.styles"; - -const WEEK_STRING_PAIRS = [ - ["SU", "일"], - ["MO", "월"], - ["TU", "화"], - ["WE", "수"], - ["TH", "목"], - ["FR", "금"], - ["SA", "토"], -]; - -export const getRecurringString = (freqEndsWithN) => { - if (!freqEndsWithN.endsWith("N")) { - throw new Error("반복 텍스트는 freq가 N으로 끝나는 경우에만 return합니다"); - } - if (freqEndsWithN.startsWith("DAILY")) { - return "일"; - } - if (freqEndsWithN.startsWith("WEEKLY")) { - return "주"; - } - if (freqEndsWithN.startsWith("MONTHLY")) { - return "개월"; - } - return "년"; -}; - -const RepeatDetail = ({ - isWeekly, - isWithN, - freq, - interval, - byweekday, - onByweekdayChange, - onIntervalChange, -}) => { - return ( -
- {isWeekly && ( - - {WEEK_STRING_PAIRS.map(([EN, KR], index) => ( - - ))} - - )} - {isWithN && ( -
- - {`${getRecurringString(freq)} 간격으로 반복합니다.`} -
- )} -
- ); -}; - -RepeatDetail.propTypes = { - isWeekly: PropTypes.bool.isRequired, - isWithN: PropTypes.bool.isRequired, - freq: PropTypes.oneOf([ - "NONE", - "DAILY", - "DAILY_N", - "WEEKLY", - "WEEKLY_N", - "MONTHLY", - "MONTHLY_N", - "YEARLY", - "YEARLY_N", - ]).isRequired, - interval: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) - .isRequired, - byweekday: PropTypes.arrayOf(PropTypes.number).isRequired, - onByweekdayChange: PropTypes.func.isRequired, - onIntervalChange: PropTypes.func.isRequired, -}; - -export default RepeatDetail; diff --git a/src/components/Common/ScheduleModal/ScheduleModal.jsx b/src/components/Common/ScheduleModal/ScheduleModal.jsx index 107466737..ff38b6523 100644 --- a/src/components/Common/ScheduleModal/ScheduleModal.jsx +++ b/src/components/Common/ScheduleModal/ScheduleModal.jsx @@ -1,32 +1,43 @@ 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 FormModal from "@/components/Common/Modal/FormModal/FormModal"; -import { SCHEDULE_MODAL_TYPE, UI_TYPE } from "@/constants/uiConstants"; +import { SCHEDULE_MODAL_TYPE } from "@/constants/uiConstants"; import { createSchedule, updateSchedule, } from "@/features/schedule/schedule-service.js"; import { closeModal, setIsLoading } from "@/features/ui/ui-slice"; -import { getSchedule } from "@/utils/calendarUtils"; +import { + calculateIsAllDay, + calculateMinUntilDateString, + getInitializeEndTimeAfterChangeStartTime, + getSchedule, + setByweekday, + validateByweekday, + validateDateTimeIsValid, + validateInterval, + validateUntil, +} from "@/utils/calendarUtils"; import { convertScheduleDataToFormValue } from "@/utils/convertSchedule"; -import DateAndTime from "./DateAndTime/DateAndTime"; +import DateAndTime from "./DateAndTime"; import Repeat from "./Repeat/Repeat"; -import RepeatDetail, { getRecurringString } from "./RepeatDetail/RepeatDetail"; +import RepeatDetail from "./RepeatDetail/RepeatDetail"; import { - ScheduleModalLayoutDiv, - TitleInput, - DetailTextarea, AllDayCheckBoxDiv, RepeatContainerDiv, FooterDiv, - SubmitButton, } from "./ScheduleModal.styles"; +import { + DetailTextarea, + ScheduleModalLayoutDiv, + TitleInput, + SubmitButton, +} from "../ScheduleModal.Shared.styles"; const initialFormValues = { title: "", @@ -42,67 +53,14 @@ const initialFormValues = { until: "", }; -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); -}; - -const setByweekday = (weekNum, prev, checked) => { - if (!checked) { - return prev.filter((num) => num !== weekNum); - } - if (prev.indexOf(weekNum) === -1) { - prev.push(weekNum); - } - return prev; -}; - -const calculateIsAllDay = (startDate, startTime, endDate, endTime) => - startDate === endDate && startTime === "00:00" && endTime === "23:59"; - const ScheduleModal = () => { const dispatch = useDispatch(); // previous form value to compare const prevFormValue = useRef(initialFormValues); // state - const { openedModal, scheduleModalMode, scheduleModalId, isLoading } = - useSelector((state) => state.ui); + const { scheduleModalMode, scheduleModalId, isLoading } = useSelector( + (state) => state.ui, + ); const [formValues, setFormValues] = useState(initialFormValues); // value const isCreateMode = scheduleModalMode === SCHEDULE_MODAL_TYPE.CREATE; @@ -183,10 +141,12 @@ const ScheduleModal = () => { setFormValues((prev) => ({ ...prev, startTime: value, - endTime: - prev.startDate === prev.endDate && value >= prev.endTime - ? value - : prev.endTime, + endTime: getInitializeEndTimeAfterChangeStartTime( + prev.startDate, + prev.endDate, + value, + prev.endTime, + ), isAllDay: calculateIsAllDay( prev.startDate, value, @@ -312,105 +272,22 @@ const ScheduleModal = () => { formValues.endDate !== "" && formValues.endTime !== "" && (formValues.freq === "NONE" || formValues.interval > 0) && - (formValues.freq === "WEEKLY" ? formValues.byweekday.length > 0 : true) && - (openedModal === UI_TYPE.SHARE_SCHEDULE - ? formValues.voteEndDate !== "" && formValues.voteEndTime !== "" - : true) + (formValues.freq === "WEEKLY" ? formValues.byweekday.length > 0 : true) ); }; - // validate form values when submit event occurs - // validate Date and Time - const checkTimeIsValid = () => { - if (formValues.startDate < formValues.endDate) { - return true; - } - - if (formValues.startDate === formValues.endDate) { - if (formValues.startTime < formValues.endTime) { - return true; - } - - toast.error("시작 시간은 종료 시간보다 빨라야 합니다."); - return false; - } - - toast.error("종료 날짜는 시작 날짜보다 동일하거나 빠를 수 없습니다."); - return false; - }; - // validate interval - const checkIntervalIsValid = () => { - if ( - formValues.freq !== "NONE" && - (!Number.isInteger(Number(formValues.interval)) || - Number(formValues.interval) <= 0) - ) { - toast.error("반복 간격은 0보다 큰 자연수여야 합니다"); - return false; - } - if (formValues.startDate === formValues.endDate) { - if (formValues.startTime < formValues.endTime) { - return true; - } - toast.error( - "반복 요일은 무조건 일정 시작 날짜에 해당하는 요일을 포함해야 합니다.", - ); - return false; - } - return true; - }; - // validate byweekday - const checkByweekdayIsValid = () => { - if (!formValues.freq.startsWith("WEEKLY")) { - return true; - } - - if ( - formValues.byweekday.indexOf(new Date(formValues.startDate).getDay()) === - -1 - ) { - toast.error( - "반복 요일은 무조건 일정 시작 날짜에 해당하는 요일을 포함해야 합니다.", - ); - return false; - } - - return true; - }; - // validate until - const checkUntilIsValid = () => { - if (formValues.until && formValues.startDate >= formValues.until) { - toast.error("반복 종료 일자는 일정 시작 날짜보다 커야 합니다."); - return false; - } - - if ( - !formValues.until || - formValues.until >= - calculateMinUntilDateString( - formValues.startDate, - formValues.freq, - formValues.interval, - ) - ) { - return true; - } - - toast.error( - `반복 종료 일자는 최소 ${formValues.interval}${getRecurringString( - formValues.freq, - )} 이후여야 합니다.`, - ); - return false; - }; - const handleSubmit = () => { // form 유효성 검사 if ( - !checkTimeIsValid() || - !checkIntervalIsValid() || - !checkByweekdayIsValid() || - !checkUntilIsValid() || + !validateDateTimeIsValid( + formValues.startDate, + formValues.startTime, + formValues.endDate, + formValues.endTime, + ) || + !validateInterval(formValues) || + !validateByweekday(formValues) || + !validateUntil(formValues) || isViewMode ) { return; @@ -503,6 +380,7 @@ const ScheduleModal = () => { disabled={isLoading || isViewMode} /> { )} - {openedModal === UI_TYPE.SHARE_SCHEDULE ? ( -
- {/* 일정 투표 종료일 - - - - setFormValues({ - ...formValues, - voteEndDate: e.target.value, - }) - } - /> - - setFormValues({ - ...formValues, - voteEndTime: e.target.value, - }) - } - /> - - */} -
- ) : ( - - - - - )} - + + + + + {isViewMode || ( theme.typography.size.s3}; - - & > h2 { - margin-bottom: 36px; - font-weight: ${({ - theme: { - typography: { weight }, - }, - }) => weight.semibold}; - font-size: ${({ - theme: { - typography: { size }, - }, - }) => size.m1}; - color: ${({ theme: { colors } }) => colors.text_01}; - } - - & input, - & textarea { - &:focus { - outline: none; - } - color: ${({ theme }) => theme.colors.text_03}; - &, - &::placeholder { - font-family: "Inter", sans-serif; - font-weight: ${({ theme }) => theme.typography.weight.medium}; - } - &::placeholder { - color: ${({ theme }) => theme.colors.disabled_text}; - } - } -`; - -export const TitleInput = styled.input` - all: unset; - width: 100%; - height: 35px; - margin-bottom: 24px; - border-bottom: 1px solid ${({ theme }) => theme.colors.text_01}; -`; - -export const DetailTextarea = styled.textarea` - all: unset; - resize: none; - width: 100%; - height: 109px; - background-color: ${({ theme }) => theme.colors.bg_01}; - display: block; - font-size: 16px; - padding: 14px; - margin-bottom: ${({ theme }) => theme.spacing.padding.medium}px; - &::placeholder { - color: ${({ theme }) => theme.colors.disabled_text}; - } -`; - export const InputLabel = styled.label` color: ${({ theme: { colors } }) => colors.text_01}; font-size: ${({ @@ -456,55 +394,11 @@ export const WeeklyDatePickerDiv = styled.div` export const FooterDiv = styled.div` display: flex; - justify-content: flex-end; -`; - -export const SubmitButton = styled.button` - display: block; - cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; - background-color: ${({ disabled, theme: { colors } }) => - disabled ? colors.btn_02 : colors.btn_01}; + flex-direction: row-reverse; + justify-content: space-between; + align-items: flex-end; - &:not(:disabled) { - &:hover { - box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); - } - &:active { - box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.25); - } + & > button { + cursor: pointer; } - - border-radius: 5px; - color: white; - width: 132px; - height: 40px; - text-align: center; - font-size: ${({ - theme: { - typography: { size }, - }, - }) => size.s2}; - font-weight: ${({ - theme: { - typography: { weight }, - }, - }) => weight.semibold}; - transition: box-shadow 0.3s; -`; - -// common -export const LabelH3 = styled.h3` - color: ${({ theme: { colors } }) => colors.text_01}; - font-size: ${({ - theme: { - typography: { size }, - }, - }) => size.s2}; - line-height: 17px; - font-weight: ${({ - theme: { - typography: { weight }, - }, - }) => weight.medium}; - margin-bottom: 12px; `; diff --git a/src/components/Common/CalendarContainer/CalendarContainer.jsx b/src/components/Common/SchedulePage/CalendarContainer/CalendarContainer.jsx similarity index 68% rename from src/components/Common/CalendarContainer/CalendarContainer.jsx rename to src/components/Common/SchedulePage/CalendarContainer/CalendarContainer.jsx index 2af30b292..3dccbf95e 100644 --- a/src/components/Common/CalendarContainer/CalendarContainer.jsx +++ b/src/components/Common/SchedulePage/CalendarContainer/CalendarContainer.jsx @@ -1,12 +1,10 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import moment from "moment"; +import { useTheme } from "styled-components"; -import { SCHEDULE_TYPE } from "@/constants/calendarConstants"; -import { getSchedulesSummary } from "@/features/schedule/schedule-service"; import { - resetCurrentDate, setCurrentMonth, setCurrentWeek, setCurrentYear, @@ -15,24 +13,20 @@ import { convertByweekdayNumberToString, getCurrentWeek, getFirstDateOfWeek, + getGroupColor, } from "@/utils/calendarUtils"; import { CalendarContainerDiv } from "./CalendarContainer.styles"; import CustomCalendar from "./CustomCalendar/CustomCalendar"; -import InviteUser from "../../SharePage/InviteUser"; -const CalendarContainer = ({ type }) => { +const CalendarContainer = () => { const dispatch = useDispatch(); const calendarRef = useRef(null); + const theme = useTheme(); const { calendarSchedules } = useSelector((state) => state.schedule); - const [selectedGroup, setSelectedGroup] = useState(null); - const [anchorEl, setAnchorEl] = useState(null); - const [inviteInput, setInviteInput] = useState(""); - const [invitationLink, setInvitationLink] = useState(""); - const schedulesToInjectIntoCalendar = calendarSchedules.map((schedule) => schedule.recurrence ? { @@ -47,12 +41,18 @@ const CalendarContainer = ({ type }) => { }, duration: new Date(schedule.endDateTime) - new Date(schedule.startDateTime), + color: schedule.isGroup + ? getGroupColor(schedule.id) + : theme.colors.disabled_text, } : { id: schedule.id, userId: schedule.userId, start: new Date(schedule.startDateTime), end: new Date(schedule.endDateTime), + color: schedule.isGroup + ? getGroupColor(schedule.id) + : theme.colors.disabled_text, }, ); @@ -106,42 +106,8 @@ const CalendarContainer = ({ type }) => { updateDateState(year, month, week); }; - const handleInviteButtonClick = (event) => { - setAnchorEl(event.currentTarget); - }; - - const handleCloseMenu = () => { - setAnchorEl(null); - setInviteInput(""); - }; - - const handleSendInvite = () => { - setAnchorEl(null); - setInviteInput(""); - }; - - useEffect(() => { - dispatch(getSchedulesSummary({ isGroup: false })); - - return () => dispatch(resetCurrentDate()); - }, []); - return ( - {type === SCHEDULE_TYPE.SHARED && ( - - )} { @@ -105,21 +105,24 @@ const getDayHeaderContentInTimeGridWeek = ({ date, text, isToday }) => { ); }; - const CustomCalendar = forwardRef( ({ fullCalendarEvents, handleDateChange }, calendarRef) => { - const { currentYear, currentMonth, currentWeek, currentCalendarView } = - useSelector((state) => state.schedule); + const { + currentYear, + currentMonth, + currentWeek, + currentCalendarView, + currentPageType, + } = useSelector((state) => state.schedule); const dispatch = useDispatch(); - const theme = useTheme(); - /** 리스트 뷰: 일정 박스를 클릭 시, 여기서 겹친 일정들 중에서 가장 작은 단위에 일정이 조회됩니다 */ const handleScheduleClick = (clickedInfo) => { if (currentCalendarView === VIEW_TYPE.DAY_GRID_MONTH) return; const { start, end } = clickedInfo.event; // 클릭한 이벤트들 중 가장 작은 단위의 처음과 끝 dispatch(getOverlappedSchedules({ start, end })); }; + // 월별 보기의 경우 날짜 박스 클릭 이벤트리스너를 등록합니다 useEffect(() => { const dateDivs = document.querySelectorAll(".fc-daygrid-day"); @@ -210,6 +213,7 @@ const CustomCalendar = forwardRef( > {getDateOptions(currentCalendarView)} + {currentPageType === SCHEDULE_PAGE_TYPE.SHARED && } { + const [isOpen, setIsOpen] = useState(false); + + const wrapperRef = useRef(); + + useOutsideClick(wrapperRef, () => isOpen && setIsOpen(false)); + + return ( + + + {isOpen && ( +
+ +
    + {extraMembers.map(({ member }, index) => ( +
  • + {`${member.nickname}님의 + {member.nickname} +
  • + ))} +
+
+ )} +
+ ); +}; + +export default ExtraGroupMembers; diff --git a/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/ExtraGroupMembers/ExtraGroupMembers.styles.js b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/ExtraGroupMembers/ExtraGroupMembers.styles.js new file mode 100644 index 000000000..a646a55f6 --- /dev/null +++ b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/ExtraGroupMembers/ExtraGroupMembers.styles.js @@ -0,0 +1,73 @@ +import styled from "styled-components"; + +export const RelativeWrapperDiv = styled.div` + position: relative; + + & > button { + width: 26px; + height: 26px; + border-radius: 50%; + background-color: ${({ theme: { colors } }) => colors.disabled_text}; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + + &.activated > svg > path:first-child { + display: none; + } + + &:hover { + opacity: 0.8; + } + } + + & > div.dropdown { + & > svg, + ul { + position: absolute; + z-index: 2; + } + + & > svg { + top: calc(100% + 3px); + left: -23px; + } + + & > ul { + top: calc(100% + 17px + 3px); /* arrow + top_padding */ + left: calc(-23px + 12px); /* bubble_position + left_padding */ + width: 122px; + height: calc(156px - 2 * 3px); /* bubble_height - 2 * top_padding */ + overflow-y: auto; + + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox */ + &::-webkit-scrollbar { + display: none; /* Safari and Chrome */ + } + + & > li { + padding: 5px 10px; + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + color: ${({ theme: { colors } }) => colors.text_03}; + word-break: break-all; + + &:not(:last-child) { + border-bottom: 1px solid + ${({ theme: { colors } }) => colors.disabled_text}; + } + + & > img { + border-radius: 50%; + background-color: ${({ theme: { colors } }) => colors.bg_01}; + } + } + } + } +`; + +/* border: 1px solid ${{ theme }}; */ diff --git a/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupInviteButton/GroupInviteButton.jsx b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupInviteButton/GroupInviteButton.jsx new file mode 100644 index 000000000..d0f977824 --- /dev/null +++ b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupInviteButton/GroupInviteButton.jsx @@ -0,0 +1,40 @@ +import React, { useRef, useState } from "react"; +import { useSelector } from "react-redux"; + +import GroupInviteLink from "@/components/Common/GroupInviteLink/GroupInviteLink"; +import useOutsideClick from "@/hooks/useOutsideClick"; + +import { RelativeDiv } from "./GroupInviteButton.styles"; + +const GroupInviteButton = () => { + const userGroupList = useSelector((state) => state.user.userGroupList); + const currentGroupId = useSelector( + (state) => state.schedule.currentGroupScheduleId, + ); + const [isOpen, setIsOpen] = useState(false); + + const wrapperRef = useRef(); + + useOutsideClick(wrapperRef, () => setIsOpen(false)); + + const groupName = userGroupList.find( + (userGroup) => userGroup.groupId === currentGroupId, + )?.name; + + return ( + + + {isOpen && currentGroupId && ( + setIsOpen(false)} + /> + )} + + ); +}; + +export default GroupInviteButton; diff --git a/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupInviteButton/GroupInviteButton.styles.js b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupInviteButton/GroupInviteButton.styles.js new file mode 100644 index 000000000..b3d859bdd --- /dev/null +++ b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupInviteButton/GroupInviteButton.styles.js @@ -0,0 +1,18 @@ +import styled from "styled-components"; + +export const RelativeDiv = styled.div` + & > button { + border-radius: 5px; + width: 98px; + background-color: ${({ theme: { colors } }) => colors.primary}; + text-align: center; + line-height: 33px; + color: ${({ theme: { colors } }) => colors.white}; + font-size: ${({ + theme: { + typography: { size }, + }, + }) => size.s2}; + cursor: pointer; + } +`; diff --git a/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupMenu.jsx b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupMenu.jsx new file mode 100644 index 000000000..cfcf41d95 --- /dev/null +++ b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupMenu.jsx @@ -0,0 +1,70 @@ +import React, { Fragment, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; + +import { SCHEDULE_COLORS } from "@/constants/calendarConstants"; +import { CrownIcon } from "@/constants/iconConstants"; +import { getGroupMembers } from "@/utils/calendarUtils"; + +import ExtraGroupMembers from "./ExtraGroupMembers/ExtraGroupMembers"; +import GroupInviteButton from "./GroupInviteButton/GroupInviteButton"; +import { GroupMemberAvatar, GroupMenuDiv } from "./GroupMenu.styles"; +import GroupSelect from "./GroupSelect/GroupSelect"; + +const GroupMenu = () => { + const userId = useSelector((state) => state.auth.user.userId); + const { isUserGroupFetching, currentGroupScheduleId } = useSelector( + (state) => state.schedule, + ); + const [groupMembers, setGroupMembers] = useState([]); + + const isUserOwner = Boolean( + groupMembers.find((groupMember) => groupMember.member.userId === userId) + ?.accessLevel === "owner", + ); + useEffect(() => { + if (currentGroupScheduleId) { + getGroupMembers((data) => setGroupMembers(data), currentGroupScheduleId); + } + }, [currentGroupScheduleId]); + + if (isUserGroupFetching) { + return ( + +
+
+
+ + ); + } + + return ( + +
+ {groupMembers.slice(0, 5).map(({ member }, index) => ( + + + {index === 0 && } + {`${member.nickname}님의 + + + ))} + {groupMembers.length > 5 && ( + + )} +
+ {isUserOwner && } + {currentGroupScheduleId && } +
+ ); +}; + +export default GroupMenu; diff --git a/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupMenu.styles.js b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupMenu.styles.js new file mode 100644 index 000000000..7ee317e33 --- /dev/null +++ b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupMenu.styles.js @@ -0,0 +1,63 @@ +import styled from "styled-components"; + +export const GroupMenuDiv = styled.div` + position: absolute; + top: 61px; + right: 0; + + height: 33px; + + display: flex; + gap: 10px; + + & > .loading { + width: 281px; + line-height: 33px; + background-color: ${({ theme: { colors } }) => colors.bg_02}; + + & > .shimmer { + animation: loading 2s infinite; + width: 10px; + height: 100%; + background-color: ${({ theme: { colors } }) => colors.white}; + transform: skewX(-40deg); + box-shadow: 0 0 30px 30px ${({ theme: { colors } }) => colors.white}; + } + + @keyframes loading { + 0% { + transform: translateX(-40px); + } + 100% { + transform: translateX(321px); + } + } + } + + & > .groupMembers { + display: flex; + align-items: center; + } +`; +export const GroupMemberAvatar = styled.div` + width: 26px; + height: 26px; + + &:not(:last-child) { + position: relative; + z-index: ${({ priority }) => priority}; + margin-right: -5px; + } + + // 그룹장 + &:first-child > svg { + position: absolute; + top: -16px; + } + + background-color: ${({ theme: { colors } }) => colors.bg_01}; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; +`; diff --git a/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupSelect/GroupSelect.jsx b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupSelect/GroupSelect.jsx new file mode 100644 index 000000000..e462c674d --- /dev/null +++ b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupSelect/GroupSelect.jsx @@ -0,0 +1,88 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +import { SCHEDULE_COLORS } from "@/constants/calendarConstants"; +import { DownArrowIcon } from "@/constants/iconConstants"; +import { changeCurrentGroupId } from "@/features/schedule/schedule-slice"; +import { openEmptyGroupNotificationModal } from "@/features/ui/ui-slice"; +import useOutsideClick from "@/hooks/useOutsideClick"; + +import { + GroupSelectWrapperDiv, + PickerDiv, + SelectButton, +} from "./GroupSelect.styles"; + +const GroupSelect = () => { + const navigate = useNavigate(); + + const dispatch = useDispatch(); + const userGroupList = useSelector((state) => state.user.userGroupList); + const currentGroupId = useSelector( + (state) => state.schedule.currentGroupScheduleId, + ); + const [isOpen, setIsOpen] = useState(false); + + const wrapperRef = useRef(); + + useOutsideClick(wrapperRef, () => isOpen && setIsOpen(false)); + + const handleOptionClick = (event) => { + dispatch(changeCurrentGroupId(Number(event.currentTarget.value))); + setIsOpen(false); + }; + + useEffect(() => { + if (userGroupList.length === 0) { + dispatch(openEmptyGroupNotificationModal()); + navigate("/personal"); + } + }, [userGroupList]); + + return ( + + setIsOpen((prev) => !prev)} + > + + { + userGroupList[ + userGroupList.findIndex((obj) => obj.groupId === currentGroupId) + ].name + } + + + + {isOpen && ( + + {userGroupList.map((obj, index) => ( + + ))} + + )} + + ); +}; + +export default GroupSelect; diff --git a/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupSelect/GroupSelect.styles.js b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupSelect/GroupSelect.styles.js new file mode 100644 index 000000000..9dd7c372c --- /dev/null +++ b/src/components/Common/SchedulePage/CalendarContainer/CustomCalendar/GroupMenu/GroupSelect/GroupSelect.styles.js @@ -0,0 +1,106 @@ +import styled from "styled-components"; + +export const GroupSelectWrapperDiv = styled.div` + position: relative; +`; + +export const SelectButton = styled.button` + width: 137px; + height: 33px; + border: 1px solid ${({ theme: { colors } }) => colors.disabled_text}; + padding: 8px 12px; + display: flex; + align-items: center; + + & > span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + & > svg { + transition: transform 0.3s; + } + + &.activated > svg { + transform: rotate(0.5turn); + } + + cursor: pointer; + font-size: ${({ + theme: { + typography: { size }, + }, + }) => size.s2}; + font-weight: ${({ + theme: { + typography: { weight }, + }, + }) => weight.medium}; +`; + +export const PickerDiv = styled.div` + position: absolute; + z-index: 2; + left: 0; + top: calc(100% + 7px); + padding: 8px 8px; + display: flex; + background-color: ${({ theme: { colors } }) => colors.white}; + width: 100%; + max-height: 200px; + + overflow-y: auto; + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox */ + &::-webkit-scrollbar { + display: none; /* Safari and Chrome */ + } + + flex-direction: column; + box-shadow: 2px 4px 8px 0px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 2px 4px 8px 0px rgba(0, 0, 0, 0.1); + -moz-box-shadow: 2px 4px 8px 0px rgba(0, 0, 0, 0.1); + font-size: ${({ + theme: { + typography: { size }, + }, + }) => size.s1}; + font-weight: ${({ + theme: { + typography: { weight }, + }, + }) => weight.regular}; + + & > button { + cursor: pointer; + min-height: 33px; + padding: 0 4px; + display: flex; + align-items: center; + gap: 8px; + + &:not(:last-child) { + border-bottom: 0.5px solid + ${({ theme: { colors } }) => colors.disabled_text}; + } + + &.selected { + background-color: ${({ theme: { colors } }) => colors.primary}; + color: ${({ theme: { colors } }) => colors.white}; + border-radius: 5px; + } + + & > img { + border-radius: 50%; + background-color: ${({ theme: { colors } }) => colors.bg_01}; + } + + & > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +`; diff --git a/src/components/ScheduleItemList/ScheduleItem/ScheduleItem.jsx b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleItem/ScheduleItem.jsx similarity index 68% rename from src/components/ScheduleItemList/ScheduleItem/ScheduleItem.jsx rename to src/components/Common/SchedulePage/ScheduleItemList/ScheduleItem/ScheduleItem.jsx index 3bddafc2e..1e6b6ecc5 100644 --- a/src/components/ScheduleItemList/ScheduleItem/ScheduleItem.jsx +++ b/src/components/Common/SchedulePage/ScheduleItemList/ScheduleItem/ScheduleItem.jsx @@ -15,7 +15,7 @@ import { openScheduleEditModal, openScheduleViewModal, } from "@/features/ui/ui-slice"; -import { checkIsAlldaySchedule } from "@/utils/calendarUtils"; +import { getGroupColor, getTimeString } from "@/utils/calendarUtils"; import { CardDiv, @@ -25,52 +25,6 @@ import { ScheduleItemRightButtonsDiv, } from "./ScheduleItem.styles"; -const getTimeString = (start, end) => { - const isAllday = checkIsAlldaySchedule(start, end); - 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}`; -}; - const ScheduleItem = ({ schedule: { id, @@ -94,7 +48,7 @@ const ScheduleItem = ({
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) => } - /> - - - - - - - - setInviteInput(e.target.value)} - InputProps={{ - endAdornment: ( - - - - ), - }} - /> - - - setInvitationLink(e.target.value)} - InputProps={{ - endAdornment: ( - - - - ), - }} - /> - - - */} -
- ); -}; - -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 }), - ) - } - > - Add-icon - 일정 후보 추가 - - - - 이번 달 일정을 등록하여 사람들에게 미리 알려주세요! - - - - ) : ( - <> - - 오늘의 할 일 - - dispatch( - openScheduleCreateModal({ - type: UI_TYPE.PERSONAL_SCHEDULE, - }), - ) - } - > - Add-icon - 일정 추가 - - - 하루동안의 할 일을 관리합니다. - - {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; +};