diff --git a/src/app/workspaces/[workspaceId]/calendar/page.tsx b/src/app/workspaces/[workspaceId]/calendar/page.tsx new file mode 100644 index 0000000..4df5a6d --- /dev/null +++ b/src/app/workspaces/[workspaceId]/calendar/page.tsx @@ -0,0 +1,15 @@ +import { CalendarPage } from '@/views/calendar'; + +interface WorkspaceCalendarPageProps { + params: Promise<{ + workspaceId: string; + }>; +} + +export default async function WorkspaceCalendarPage({ + params, +}: WorkspaceCalendarPageProps) { + const { workspaceId } = await params; + + return ; +} diff --git a/src/entities/calendar-event/index.ts b/src/entities/calendar-event/index.ts new file mode 100644 index 0000000..e286977 --- /dev/null +++ b/src/entities/calendar-event/index.ts @@ -0,0 +1,7 @@ +export { getMockCalendarEventsByWorkspaceId } from './model/mock-calendar-events-by-workspace'; +export type { + CalendarEvent, + CalendarEventColor, + CalendarEventFormValues, +} from './model/calendar-event.types'; +export { CalendarEventChip } from './ui/CalendarEventChip'; diff --git a/src/entities/calendar-event/model/calendar-event.types.ts b/src/entities/calendar-event/model/calendar-event.types.ts new file mode 100644 index 0000000..c62aec1 --- /dev/null +++ b/src/entities/calendar-event/model/calendar-event.types.ts @@ -0,0 +1,16 @@ +export type CalendarEventColor = 'violet' | 'purple' | 'blue' | 'green' | 'amber' | 'coral' | 'pink'; + +export interface CalendarEvent { + id: string; + workspaceId: string; + title: string; + date: string; + time: string | null; + color: CalendarEventColor; +} + +export interface CalendarEventFormValues { + title: string; + time: string; + color: CalendarEventColor; +} diff --git a/src/entities/calendar-event/model/mock-calendar-events-by-workspace.ts b/src/entities/calendar-event/model/mock-calendar-events-by-workspace.ts new file mode 100644 index 0000000..15cb513 --- /dev/null +++ b/src/entities/calendar-event/model/mock-calendar-events-by-workspace.ts @@ -0,0 +1,142 @@ +import type { CalendarEvent } from './calendar-event.types'; + +const mockCalendarEventsByWorkspaceId: Record = { + test: [ + { + id: 'calendar-event-1', + workspaceId: 'test', + title: 'API 명세 마감', + date: '2025-07-02', + time: null, + color: 'violet', + }, + { + id: 'calendar-event-2', + workspaceId: 'test', + title: '백로그 정리', + date: '2025-07-02', + time: '오후 1:00', + color: 'blue', + }, + { + id: 'calendar-event-3', + workspaceId: 'test', + title: '와이어프레임', + date: '2025-07-03', + time: null, + color: 'purple', + }, + { + id: 'calendar-event-4', + workspaceId: 'test', + title: '스프린트', + date: '2025-07-03', + time: null, + color: 'blue', + }, + { + id: 'calendar-event-5', + workspaceId: 'test', + title: '사용자 리서치', + date: '2025-07-05', + time: null, + color: 'blue', + }, + { + id: 'calendar-event-6', + workspaceId: 'test', + title: '회의 안건 정리', + date: '2025-07-05', + time: '오전 10:00', + color: 'amber', + }, + { + id: 'calendar-event-7', + workspaceId: 'test', + title: '랜딩 디자인', + date: '2025-07-08', + time: null, + color: 'violet', + }, + { + id: 'calendar-event-8', + workspaceId: 'test', + title: '카피 문구 검토', + date: '2025-07-08', + time: '오후 2:00', + color: 'purple', + }, + { + id: 'calendar-event-9', + workspaceId: 'test', + title: 'MVP 완성', + date: '2025-07-14', + time: null, + color: 'green', + }, + { + id: 'calendar-event-10', + workspaceId: 'test', + title: '중간 점검 회의', + date: '2025-07-14', + time: '오후 4:00', + color: 'blue', + }, + { + id: 'calendar-event-11', + workspaceId: 'test', + title: 'QA 시작', + date: '2025-07-22', + time: null, + color: 'coral', + }, + { + id: 'calendar-event-12', + workspaceId: 'test', + title: '버그 우선순위 정리', + date: '2025-07-22', + time: '오전 11:00', + color: 'pink', + }, + { + id: 'calendar-event-13', + workspaceId: 'test', + title: '배포 준비', + date: '2025-07-28', + time: null, + color: 'violet', + }, + { + id: 'calendar-event-14', + workspaceId: 'test', + title: '릴리즈 체크리스트', + date: '2025-07-28', + time: '오후 1:00', + color: 'green', + }, + { + id: 'calendar-event-15', + workspaceId: 'test', + title: '최종 발표 리허설', + date: '2025-07-30', + time: '오후 3:00', + color: 'violet', + }, + { + id: 'calendar-event-16', + workspaceId: 'test', + title: '발표 자료 검수', + date: '2025-07-30', + time: '오후 5:00', + color: 'amber', + }, + ], +}; + +export function getMockCalendarEventsByWorkspaceId(workspaceId: string): CalendarEvent[] { + return ( + mockCalendarEventsByWorkspaceId[workspaceId]?.map((event) => ({ + ...event, + })) ?? [] + ); +} diff --git a/src/entities/calendar-event/ui/CalendarEventChip.tsx b/src/entities/calendar-event/ui/CalendarEventChip.tsx new file mode 100644 index 0000000..2a1cc2a --- /dev/null +++ b/src/entities/calendar-event/ui/CalendarEventChip.tsx @@ -0,0 +1,30 @@ +import { cn } from '@/shared/lib/utils'; +import type { CalendarEvent, CalendarEventColor } from '../model/calendar-event.types'; + +const colorClassNames: Record = { + violet: 'bg-[#6b5cff]', + purple: 'bg-[#7f5cff]', + blue: 'bg-[#2f7df6]', + green: 'bg-[#10b74a]', + amber: 'bg-[#ff9f0a]', + coral: 'bg-[#ff6565]', + pink: 'bg-[#eb2f96]', +}; + +interface CalendarEventChipProps { + event: CalendarEvent; +} + +export function CalendarEventChip({ event }: CalendarEventChipProps) { + return ( + + {event.title} + + ); +} diff --git a/src/entities/workspace/model/mock-workspace.ts b/src/entities/workspace/model/mock-workspace.ts index 73d66c8..6e20607 100644 --- a/src/entities/workspace/model/mock-workspace.ts +++ b/src/entities/workspace/model/mock-workspace.ts @@ -3,6 +3,11 @@ import { cache } from 'react'; import type { Workspace } from './workspace.types'; const mockWorkspacesById: Record = { + test: { + id: 'test', + name: '캡스톤 디자인 팀', + purpose: 'team-project', + }, 'store-workspace': { id: 'store-workspace', name: '카페 그레이 운영', diff --git a/src/features/manage-calendar/index.ts b/src/features/manage-calendar/index.ts new file mode 100644 index 0000000..57d4a72 --- /dev/null +++ b/src/features/manage-calendar/index.ts @@ -0,0 +1 @@ +export { CalendarView } from './ui/CalendarView'; diff --git a/src/features/manage-calendar/model/calendar-utils.ts b/src/features/manage-calendar/model/calendar-utils.ts new file mode 100644 index 0000000..5bd02e2 --- /dev/null +++ b/src/features/manage-calendar/model/calendar-utils.ts @@ -0,0 +1,40 @@ +export interface CalendarDayCell { + isoDate: string; + dayNumber: number; + isCurrentMonth: boolean; +} + +function formatIsoDate(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +export function createCalendarMonthLabel(currentMonth: Date) { + return `${currentMonth.getFullYear()}년 ${currentMonth.getMonth() + 1}월`; +} + +export function createCalendarMonthGrid(currentMonth: Date): CalendarDayCell[] { + const year = currentMonth.getFullYear(); + const monthIndex = currentMonth.getMonth(); + const firstDayOfMonth = new Date(year, monthIndex, 1); + const startDayIndex = firstDayOfMonth.getDay(); + const gridStartDate = new Date(year, monthIndex, 1 - startDayIndex); + + return Array.from({ length: 35 }, (_, index) => { + const date = new Date(gridStartDate); + date.setDate(gridStartDate.getDate() + index); + + return { + isoDate: formatIsoDate(date), + dayNumber: date.getDate(), + isCurrentMonth: date.getMonth() === monthIndex, + }; + }); +} + +export function getInitialCalendarDate() { + return new Date(2025, 6, 1); +} diff --git a/src/features/manage-calendar/model/use-calendar-events-store.ts b/src/features/manage-calendar/model/use-calendar-events-store.ts new file mode 100644 index 0000000..6ae5feb --- /dev/null +++ b/src/features/manage-calendar/model/use-calendar-events-store.ts @@ -0,0 +1,44 @@ +'use client'; + +import { create } from 'zustand'; +import type { CalendarEvent } from '@/entities/calendar-event'; + +interface CalendarEventsStore { + calendarEventsByWorkspaceId: Record; + initializeWorkspace: (workspaceId: string, events: CalendarEvent[]) => void; + addCalendarEvent: (workspaceId: string, event: CalendarEvent) => void; + removeCalendarEvent: (workspaceId: string, eventId: string) => void; +} + +export const useCalendarEventsStore = create((set) => ({ + calendarEventsByWorkspaceId: {}, + initializeWorkspace: (workspaceId, events) => + set((state) => { + if (state.calendarEventsByWorkspaceId[workspaceId]) { + return state; + } + + return { + calendarEventsByWorkspaceId: { + ...state.calendarEventsByWorkspaceId, + [workspaceId]: events, + }, + }; + }), + addCalendarEvent: (workspaceId, event) => + set((state) => ({ + calendarEventsByWorkspaceId: { + ...state.calendarEventsByWorkspaceId, + [workspaceId]: [...(state.calendarEventsByWorkspaceId[workspaceId] ?? []), event], + }, + })), + removeCalendarEvent: (workspaceId, eventId) => + set((state) => ({ + calendarEventsByWorkspaceId: { + ...state.calendarEventsByWorkspaceId, + [workspaceId]: (state.calendarEventsByWorkspaceId[workspaceId] ?? []).filter( + (event) => event.id !== eventId, + ), + }, + })), +})); diff --git a/src/features/manage-calendar/ui/CalendarView.tsx b/src/features/manage-calendar/ui/CalendarView.tsx new file mode 100644 index 0000000..6f37795 --- /dev/null +++ b/src/features/manage-calendar/ui/CalendarView.tsx @@ -0,0 +1,399 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { ChevronLeft, ChevronRight, Plus, X } from 'lucide-react'; +import { + CalendarEventChip, + getMockCalendarEventsByWorkspaceId, + type CalendarEvent, + type CalendarEventColor, + type CalendarEventFormValues, +} from '@/entities/calendar-event'; +import { plusJakartaSans } from '@/shared/lib/fonts'; +import { + createCalendarMonthGrid, + createCalendarMonthLabel, + getInitialCalendarDate, +} from '../model/calendar-utils'; +import { useCalendarEventsStore } from '../model/use-calendar-events-store'; + +interface CalendarViewProps { + workspaceId: string; +} + +const weekdayLabels = ['일', '월', '화', '수', '목', '금', '토'] as const; +const colorOptions: CalendarEventColor[] = [ + 'violet', + 'purple', + 'blue', + 'green', + 'amber', + 'coral', + 'pink', +]; + +const colorButtonClassNames: Record = { + violet: 'bg-[#6b5cff]', + purple: 'bg-[#8b5cf6]', + blue: 'bg-[#3b82f6]', + green: 'bg-[#10b74a]', + amber: 'bg-[#ff9f0a]', + coral: 'bg-[#ff6565]', + pink: 'bg-[#eb2f96]', +}; + +const colorDotClassNames: Record = { + violet: 'bg-[#6b5cff]', + purple: 'bg-[#8b5cf6]', + blue: 'bg-[#3b82f6]', + green: 'bg-[#10b74a]', + amber: 'bg-[#ff9f0a]', + coral: 'bg-[#ff6565]', + pink: 'bg-[#eb2f96]', +}; + +const defaultFormValues: CalendarEventFormValues = { + title: '', + time: '', + color: 'violet', +}; + +function createCalendarEventId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `calendar-event-${crypto.randomUUID()}`; + } + + return `calendar-event-${Math.random().toString(36).slice(2, 10)}`; +} + +function formatSelectedDateLabel(isoDate: string) { + const [year, month, day] = isoDate.split('-'); + return `${year}년 ${Number(month)}월 ${Number(day)}일`; +} + +export function CalendarView({ workspaceId }: CalendarViewProps) { + const initialEvents = getMockCalendarEventsByWorkspaceId(workspaceId); + const initializeWorkspace = useCalendarEventsStore((state) => state.initializeWorkspace); + const addCalendarEvent = useCalendarEventsStore((state) => state.addCalendarEvent); + const removeCalendarEvent = useCalendarEventsStore((state) => state.removeCalendarEvent); + const storedEvents = useCalendarEventsStore((state) => state.calendarEventsByWorkspaceId[workspaceId]); + + useEffect(() => { + initializeWorkspace(workspaceId, initialEvents); + }, [initialEvents, initializeWorkspace, workspaceId]); + + const calendarEvents = storedEvents ?? initialEvents; + const [currentMonth, setCurrentMonth] = useState(getInitialCalendarDate); + const [selectedDate, setSelectedDate] = useState('2025-07-30'); + const [isAddEventOpen, setIsAddEventOpen] = useState(false); + const [formValues, setFormValues] = useState(defaultFormValues); + const [hasSubmitted, setHasSubmitted] = useState(false); + + const monthGrid = createCalendarMonthGrid(currentMonth); + const monthLabel = createCalendarMonthLabel(currentMonth); + const isTitleValid = formValues.title.trim().length > 0; + const [year, month, day] = selectedDate.split('-'); + const modalDateLabel = `${year}년 ${Number(month)}월 ${Number(day)}일 일정 추가`; + const selectedDateLabel = formatSelectedDateLabel(selectedDate); + + const eventsByDate = calendarEvents.reduce>((accumulator, event) => { + accumulator[event.date] = [...(accumulator[event.date] ?? []), event]; + return accumulator; + }, {}); + const selectedDateEvents = eventsByDate[selectedDate] ?? []; + + const openAddEventModal = (isoDate: string) => { + setSelectedDate(isoDate); + setFormValues(defaultFormValues); + setHasSubmitted(false); + setIsAddEventOpen(true); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + setHasSubmitted(true); + + if (!isTitleValid) { + return; + } + + addCalendarEvent(workspaceId, { + id: createCalendarEventId(), + workspaceId, + title: formValues.title.trim(), + date: selectedDate, + time: formValues.time.trim() || null, + color: formValues.color, + }); + + setIsAddEventOpen(false); + }; + + return ( + + + + + + + + setCurrentMonth( + (current) => new Date(current.getFullYear(), current.getMonth() - 1, 1), + ) + } + className="text-brand-muted transition hover:text-brand-ink" + aria-label="이전 달" + > + + + + {monthLabel} + + + setCurrentMonth( + (current) => new Date(current.getFullYear(), current.getMonth() + 1, 1), + ) + } + className="text-brand-muted transition hover:text-brand-ink" + aria-label="다음 달" + > + + + + + openAddEventModal(selectedDate)} + className="inline-flex h-9 items-center gap-2 rounded-full bg-brand px-4 text-[13px] font-bold text-white shadow-[0_10px_22px_rgba(91,78,232,0.2)] transition hover:brightness-105" + > + + 일정 추가 + + + + + + {weekdayLabels.map((label, index) => ( + + {label} + + ))} + + + + {monthGrid.map((cell, index) => { + const dayEvents = eventsByDate[cell.isoDate] ?? []; + const isSelected = cell.isoDate === selectedDate; + + return ( + setSelectedDate(cell.isoDate)} + className={`relative min-h-[98px] border-r border-b border-[rgba(91,78,232,0.1)] px-[8px] pt-[5px] pb-[8px] text-left align-top transition hover:bg-brand-soft/30 sm:min-h-[108px] ${ + (index + 1) % 7 === 0 ? 'border-r-0' : '' + } ${isSelected ? 'bg-[rgba(238,240,251,0.6)]' : ''}`} + > + + + {cell.dayNumber} + + + + {dayEvents.slice(0, 2).map((calendarEvent) => ( + + ))} + + + ); + })} + + + + + + + + + {isAddEventOpen ? ( + + + + + {modalDateLabel} + + setIsAddEventOpen(false)} + className="rounded-full p-1 text-brand-muted transition hover:bg-brand-soft hover:text-brand-ink" + aria-label="모달 닫기" + > + + + + + + + + 일정 이름 * + + + setFormValues((current) => ({ ...current, title: event.target.value })) + } + placeholder="예: 팀 회의" + className={`mt-2 h-11 w-full rounded-[16px] bg-[#f1f3fb] px-4 text-[14px] text-brand-ink placeholder:text-[#a8afc8] outline-none transition ${ + hasSubmitted && !isTitleValid ? 'ring-1 ring-[#ff6b6b]' : 'focus:ring-1 focus:ring-brand' + }`} + /> + {hasSubmitted && !isTitleValid ? ( + + 일정 이름을 입력해주세요. + + ) : null} + + + + 시간 (선택) + + setFormValues((current) => ({ ...current, time: event.target.value })) + } + placeholder="예: 오후 3:00" + className="mt-2 h-11 w-full rounded-[16px] bg-[#f1f3fb] px-4 text-[14px] text-brand-ink placeholder:text-[#a8afc8] outline-none transition focus:ring-1 focus:ring-brand" + /> + + + + 색상 + + {colorOptions.map((color) => ( + setFormValues((current) => ({ ...current, color }))} + className={`size-6 rounded-full ${colorButtonClassNames[color]} ${ + formValues.color === color ? 'ring-2 ring-offset-2 ring-[#d7dcf6]' : '' + }`} + aria-label={`${color} 색상 선택`} + /> + ))} + + + + + setIsAddEventOpen(false)} + className="h-10 rounded-full bg-[#f1f3fb] text-[15px] font-semibold text-brand-ink" + > + 취소 + + + 추가 + + + + + + ) : null} + + ); +} diff --git a/src/views/calendar/index.ts b/src/views/calendar/index.ts new file mode 100644 index 0000000..a6e0d55 --- /dev/null +++ b/src/views/calendar/index.ts @@ -0,0 +1 @@ +export { default as CalendarPage } from './ui/CalendarPage'; diff --git a/src/views/calendar/ui/CalendarPage.tsx b/src/views/calendar/ui/CalendarPage.tsx new file mode 100644 index 0000000..3a7d5aa --- /dev/null +++ b/src/views/calendar/ui/CalendarPage.tsx @@ -0,0 +1,9 @@ +import { CalendarView } from '@/features/manage-calendar'; + +interface CalendarPageProps { + workspaceId: string; +} + +export default function CalendarPage({ workspaceId }: CalendarPageProps) { + return ; +}
+ 일정 이름을 입력해주세요. +
색상