오늘은 진행하던 프로젝트에서 타입 스크립트를 사용하여 캘린더 퍼블리싱을 진행하였습니다.

import React, { useState } from 'react';
import styled from 'styled-components';
import CalendarButton from './CalendarButton'; // CalendarButton 컴포넌트 import

const CalendarContainer = styled.div`
    margin-top: 40px;
`;

const CalendarNavigation = styled.div`
    display: flex;
    justify-content: space-between;
    padding: 0 10px;
`;

const NavButton = styled.button`
    background-color: transparent;
    border: none;
    color: #000000;
    font-size: 26px;
    cursor: pointer;

    &:hover {
        color: #007bff;
    }
`;

const MonthYearTitle = styled.span`
    font-size: 26px;
    font-weight: bold;
`;

const WeekdaysRow = styled.div`
    display: flex;
    justify-content: space-around;
    text-align: center;
    font-weight: medium;
    color: #2f2f2f;
    margin-top: 25px;
    margin-bottom: 10px;
`;

interface WeekdayCellProps {
    isSunday?: boolean;
    isSaturday?: boolean;
}

const WeekdayCell = styled.div<WeekdayCellProps>`
    width: 14.28%;
    color: ${(props: WeekdayCellProps): string => {
        return props.isSunday
            ? '#ff4343'
            : props.isSaturday
              ? '#0085ff'
              : '#2f2f2f';
    }};

    abbr {
        text-decoration: none;
    }
`;

const DaysGrid = styled.div`
    display: flex;
    flex-direction: column;
`;

const WeekRow = styled.div`
    display: flex;
    width: 100%;
`;

interface DayCellProps {
    isCurrentMonth: boolean;
    hasEvent?: boolean;
}

const DayCell = styled.div<DayCellProps>`
    flex: 1;
    padding: 15px 15px;
    font-size: 18px;
    background-color: ${(props: DayCellProps): string => {
        if (props.hasEvent) {
            return '#e0f0ff'; // 일정 있는 날 색상 변경 (원래 오늘 날짜 색상)
        }
        return '#ececec'; // 다른 날짜 기본 배경색
    }};
    border-radius: 13px;
    text-align: center;
    margin: 10px 5px;
    cursor: pointer;
    color: ${(props: DayCellProps): string => {
        return props.isCurrentMonth ? '#000' : '#aaa';
    }};

    &:hover {
        background-color: ${(props: DayCellProps): string => {
            return props.hasEvent ? '#d4e5f6' : '#e0f0ff'; // hover 시 일정 있는 날짜 색상 유지
        }};
    }

    position: relative;
`;

const EventDot = styled.div`
    width: 6px;
    height: 6px;
    background-color: #40a3ff;
    border-radius: 50%;
    position: absolute;
    bottom: 5px;
    left: 50%;
    transform: translateX(-50%);
`;

const ScheduleButtonContainer = styled.div`
    display: flex;
    justify-content: flex-end;
    margin-top: 20px;
`;

const ProjectCalendar: React.FC = () => {
    const today = new Date();
    const [currentDate, setCurrentDate] = useState<Date>(new Date());
    const [isSelectingDates, setIsSelectingDates] = useState<boolean>(false);
    const [selectedStartDate, setSelectedStartDate] = useState<Date | null>(
        null,
    );
    const [selectedEndDate, setSelectedEndDate] = useState<Date | null>(null);

    // 일정이 있는 날짜 목록 (예: 이벤트 데이터)
    const eventDates = [
        new Date(2024, 9, 14), // 2024년 10월 14일에 일정
        new Date(2024, 9, 20), // 2024년 10월 20일에 일정
        // 추가 일정...
    ];

    const getMonthYear = (date: Date): string => {
        return date.toLocaleString('en-US', { month: 'long' });
    };

    const prevMonth = (): void => {
        setCurrentDate(
            new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1),
        );
    };

    const nextMonth = (): void => {
        setCurrentDate(
            new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1),
        );
    };

    const getWeekdays = (): string[] => {
        return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
    };

    const generateCalendar = (): Date[][] => {
        const startOfMonth = new Date(
            currentDate.getFullYear(),
            currentDate.getMonth(),
            1,
        );
        const endOfMonth = new Date(
            currentDate.getFullYear(),
            currentDate.getMonth() + 1,
            0,
        );

        const dates: Date[][] = [];
        const current = new Date(startOfMonth);
        current.setDate(current.getDate() - current.getDay());

        while (current <= endOfMonth || current.getDay() !== 0) {
            const week: Date[] = [];

            for (let i = 0; i < 7; i += 1) {
                week.push(new Date(current));
                current.setDate(current.getDate() + 1);
            }

            dates.push(week);
        }

        return dates;
    };

    const isSameDay = (date1: Date, date2: Date): boolean => {
        return (
            date1.getFullYear() === date2.getFullYear() &&
            date1.getMonth() === date2.getMonth() &&
            date1.getDate() === date2.getDate()
        );
    };

    const handleDateClick = (date: Date): void => {
        if (isSelectingDates) {
            if (!selectedStartDate) {
                setSelectedStartDate(date);
            } else if (!selectedEndDate) {
                if (date >= selectedStartDate) {
                    setSelectedEndDate(date);
                } else {
                    setSelectedEndDate(selectedStartDate);
                    setSelectedStartDate(date);
                }
            } else {
                setSelectedStartDate(date);
                setSelectedEndDate(null);
            }
        } else {
            console.log(date);
        }
    };

    const dates: Date[][] = generateCalendar();

    return (
        <CalendarContainer>
            <CalendarNavigation>
                <NavButton type="button" onClick={prevMonth}>
                    &lt;
                </NavButton>
                <MonthYearTitle>{getMonthYear(currentDate)}</MonthYearTitle>
                <NavButton type="button" onClick={nextMonth}>
                    &gt;
                </NavButton>
            </CalendarNavigation>
            <WeekdaysRow>
                {getWeekdays().map((day: string, index: number) => {
                    return (
                        <WeekdayCell
                            key={day}
                            isSunday={index === 0}
                            isSaturday={index === 6}
                        >
                            <abbr title={day}>{day}</abbr>
                        </WeekdayCell>
                    );
                })}
            </WeekdaysRow>
            <DaysGrid>
                {dates.map((week: Date[]) => {
                    return (
                        <WeekRow key={week[0].toISOString()}>
                            {week.map((date: Date) => {
                                const isCurrentMonth =
                                    date.getMonth() === currentDate.getMonth();

                                // 일정이 있는 날짜인지 확인
                                const hasEvent = eventDates.some((eventDate) =>
                                    isSameDay(eventDate, date),
                                );

                                return (
                                    <DayCell
                                        key={date.toISOString()}
                                        isCurrentMonth={isCurrentMonth}
                                        hasEvent={hasEvent}
                                        onClick={() => {
                                            handleDateClick(date);
                                        }}
                                    >
                                        {date.getDate()}
                                        {hasEvent && <EventDot />}
                                    </DayCell>
                                );
                            })}
                        </WeekRow>
                    );
                })}
            </DaysGrid>
            <ScheduleButtonContainer>
                <CalendarButton
                    isSelectingDates={isSelectingDates}
                    hasSelectedDates={!!selectedStartDate}
                    onClick={() => {
                        if (isSelectingDates) {
                            if (selectedStartDate && selectedEndDate) {
                                console.log(
                                    '선택된 시작 날짜:',
                                    selectedStartDate,
                                );
                                console.log(
                                    '선택된 종료 날짜:',
                                    selectedEndDate,
                                );
                                setIsSelectingDates(false);
                                setSelectedStartDate(null);
                                setSelectedEndDate(null);
                            } else if (selectedStartDate) {
                                console.log('종료 날짜를 선택하세요.');
                            }
                        } else {
                            setIsSelectingDates(true);
                            setSelectedStartDate(null);
                            setSelectedEndDate(null);
                            console.log('회의 일정잡기 버튼 클릭');
                        }
                    }}
                    onCancel={() => {
                        setIsSelectingDates(false);
                        setSelectedStartDate(null);
                        setSelectedEndDate(null);
                        console.log('취소 버튼 클릭');
                    }}
                />
            </ScheduleButtonContainer>
        </CalendarContainer>
    );
};

export default ProjectCalendar;
import React from 'react';
import styled from 'styled-components';

interface CalendarButtonProps {
    isSelectingDates: boolean;
    hasSelectedDates: boolean;
    onClick: () => void;
    onCancel?: () => void;
}

const ButtonWrapper = styled.div`
    display: flex;
    gap: 10px; /* 버튼 간의 간격 */
    margin-top: -2%;
`;

const Button = styled.button<CalendarButtonProps>`
    background-color: ${(props): string => {
        return props.isSelectingDates && !props.hasSelectedDates
            ? '#CCCCCC'
            : '#40a3ff';
    }};
    color: #ffffff;
    border: none;
    padding: 8px 20px;
    font-size: 16px;
    border-radius: 10px;
    cursor: pointer;

    &:hover {
        background-color: ${(props): string => {
            return props.isSelectingDates && !props.hasSelectedDates
                ? '#CCCCCC'
                : '#3392e6';
        }};
    }
`;

const CancelButton = styled.button`
    background-color: #40a3ff;
    color: #ffffff;
    border: none;
    padding: 8px 20px;
    font-size: 16px;
    border-radius: 10px;
    cursor: pointer;

    &:hover {
        background-color: #3392e6;
    }
`;

const CalendarButton: React.FC<CalendarButtonProps> = ({
    isSelectingDates,
    hasSelectedDates,
    onClick,
    onCancel,
}) => {
    console.log('isSelectingDates:', isSelectingDates);
    console.log('onCancel:', onCancel);

    return (
        <ButtonWrapper>
            {isSelectingDates && onCancel && (
                <CancelButton type="button" onClick={onCancel}>
                    취소
                </CancelButton>
            )}
            <Button
                type="button"
                isSelectingDates={isSelectingDates}
                hasSelectedDates={hasSelectedDates}
                onClick={onClick}
            >
                회의 일정잡기
            </Button>
        </ButtonWrapper>
    );
};

// 기본값 설정
CalendarButton.defaultProps = {
    onCancel: () => {}, // 기본값으로 빈 함수 설정
};

export default CalendarButton;
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { ActiveSettingIcon, MemberIcon, MemberAddIcon, AddIcon } from '@assets';
import {
    MemberTable,
    TitleTab,
    EventTab,
    ActionButton,
    EventFile,
} from '@components';
import { FlexCol, FlexRow, ItemsCenterRow, ItemsCenterStartRow } from '@styles';
import ProjectCalendar from '@components/calendar/ProjectCalendar';
import Layout from '../Layout';

// -- 스타일 컴포넌트 --
const Container = styled(FlexRow)`
    width: 100%;
    max-width: 1100px;
`;

const ContentArea = styled(FlexCol)`
    width: 50%;
    overflow-y: auto;
    height: calc(100vh - 50px);
    padding: 40px;
    gap: 30px;
    ::-webkit-scrollbar {
        width: 0;
        background: transparent;
    }
    -ms-overflow-style: none;
    scrollbar-width: none;
`;

const IconImage = styled.img<{ $width: number; $height: number }>`
    width: ${(props) => props.$width}px;
    height: ${(props) => props.$height}px;
    cursor: pointer;
`;

// 왼쪽 영역
const LeftContentArea = styled(ContentArea)``;

const MemberArea = styled(FlexCol)``;

const MemberTabArea = styled(ItemsCenterRow)`
    gap: 6px;
    padding-left: 3px;
    font-size: 20px;
    color: var(--color-gray-600);
`;

const MemberAddArea = styled(ItemsCenterStartRow)`
    padding: 5px;
    border-bottom: 0.5px solid var(--color-gray-300);
`;

const MemberAddButton = styled(ItemsCenterRow)`
    background-color: var(--color-gray-50);
    border-radius: 3px;
    padding: 2px;
    gap: 1px;
    font-size: 7px;
    color: var(--color-gray-400);
    cursor: pointer;
`;

const ContentTabArea = styled(FlexCol)``;

const ContentFileArea = styled(FlexCol)`
    gap: 4px;
`;

// 오른쪽 영역
const RightContentArea = styled(ContentArea)``;

// fetchData 중에서 일부 데이터들
const fetchEventData = async (): Promise<{
    meetings: { id: string; meetingName: string; dateTime: string }[];
    schedules: { id: string; meetingName: string; dateTime: string }[];
    members: {
        id: string;
        name: string;
        role: string | null;
        email: string;
        permission: string;
    }[];
}> => {
    return {
        meetings: [
            {
                id: '1',
                meetingName: '프로젝트 킥오프',
                dateTime: '2024-09-14T10:30:00',
            },
            {
                id: '2',
                meetingName: '디자인 리뷰',
                dateTime: '2024-09-15T14:00:00',
            },
        ],
        schedules: [
            {
                id: '3',
                meetingName: '프론트 디자인 회의 일정',
                dateTime: '2024-09-20T10:30:00',
            },
            {
                id: '4',
                meetingName: '프론트 기능 명세 일정',
                dateTime: '2024-09-15T14:00:00',
            },
        ],
        members: [
            {
                id: '1',
                name: '신진욱',
                role: 'FE',
                email: '[email protected]',
                permission: 'owner',
            },
            {
                id: '2',
                name: '박건민',
                role: 'DE',
                email: '[email protected]',
                permission: 'member',
            },
        ],
    };
};

const ProjectDetailPage: React.FC = () => {
    const [activeTab, setActiveTab] = useState<string>('meeting');
    const [meetingData, setMeetingData] = useState<
        { id: string; meetingName: string; dateTime: string }[]
    >([]);
    const [scheduleData, setScheduleData] = useState<
        { id: string; meetingName: string; dateTime: string }[]
    >([]);
    const [memberData, setMemberData] = useState<
        {
            id: string;
            name: string;
            role: string | null;
            email: string;
            permission: string;
        }[]
    >([]);

    const onClickMeetCreateButton = (): void => {
        alert('회의 생성');
    };

    const onClickMemberSettingButton = (): void => {
        alert('멤버 설정');
    };

    const onClickMemberInviteButton = (): void => {
        alert('멤버 초대');
    };

    useEffect(() => {
        const fetchData = async (): Promise<void> => {
            const { meetings, schedules, members } = await fetchEventData();
            setMeetingData(meetings);
            setScheduleData(schedules);
            setMemberData(members);
        };
        fetchData();
    }, []);

    const eventData = activeTab === 'meeting' ? meetingData : scheduleData;

    return (
        <Layout>
            <Container>
                <LeftContentArea>
                    <TitleTab type="project" title="새로운 프로젝트" />
                    <MemberArea>
                        <MemberTabArea>
                            <IconImage
                                src={MemberIcon}
                                $width={28}
                                $height={20}
                            />
                            Member
                            <IconImage
                                src={ActiveSettingIcon}
                                $width={16}
                                $height={16}
                                onClick={onClickMemberSettingButton}
                            />
                        </MemberTabArea>
                        <MemberTable data={memberData} />
                        <MemberAddArea>
                            <MemberAddButton
                                onClick={onClickMemberInviteButton}
                            >
                                <IconImage
                                    src={MemberAddIcon}
                                    $width={7}
                                    $height={7}
                                />
                                추가하기
                            </MemberAddButton>
                        </MemberAddArea>
                    </MemberArea>

                    <ContentTabArea>
                        <EventTab
                            activeTab={activeTab}
                            onClickTab={setActiveTab}
                        />
                        <ContentFileArea>
                            {activeTab === 'meeting' && (
                                <ActionButton
                                    icon={AddIcon}
                                    label="회의 생성"
                                    onClick={onClickMeetCreateButton}
                                />
                            )}
                            {eventData.map((event) => (
                                <EventFile
                                    key={event.id}
                                    meetingName={event.meetingName}
                                    dateTime={event.dateTime}
                                />
                            ))}
                        </ContentFileArea>
                    </ContentTabArea>
                </LeftContentArea>

                <RightContentArea>
                    <ProjectCalendar />
                </RightContentArea>
            </Container>
        </Layout>
    );
};

export default ProjectDetailPage;

✏️ 소감

이번 프로젝트를 통해 React와 TypeScript를 활용한 컴포넌트 기반의 개발 방식을 깊이 이해하게 되었습니다. 특히, 상태 관리와 이벤트 처리에 대한 경험이 풍부해졌고, styled-components를 통해 UI를 더 직관적으로 설계할 수 있음을 느꼈습니다. 또한, 일정 관리와 같은 기능을 구현하며 데이터 처리와 비동기 작업의 중요성을 다시 한번 깨달았습니다. 이 과정에서 코드의 가독성과 유지 보수성을 높이는 방법에 대해서도 많은 배움을 얻었습니다.