From 80217dcdcd94649faad633517f435f7e7f98fbd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=80=EC=9B=85?= Date: Thu, 9 Jul 2026 15:08:59 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/calendar/page.tsx | 15 + src/entities/calendar-event/index.ts | 7 + .../model/calendar-event.types.ts | 16 + .../mock-calendar-events-by-workspace.ts | 142 +++++++ .../calendar-event/ui/CalendarEventChip.tsx | 30 ++ .../workspace/model/mock-workspace.ts | 5 + src/features/manage-calendar/index.ts | 1 + .../manage-calendar/model/calendar-utils.ts | 40 ++ .../model/use-calendar-events-store.ts | 44 ++ .../manage-calendar/ui/CalendarView.tsx | 399 ++++++++++++++++++ src/views/calendar/index.ts | 1 + src/views/calendar/ui/CalendarPage.tsx | 9 + 12 files changed, 709 insertions(+) create mode 100644 src/app/workspaces/[workspaceId]/calendar/page.tsx create mode 100644 src/entities/calendar-event/index.ts create mode 100644 src/entities/calendar-event/model/calendar-event.types.ts create mode 100644 src/entities/calendar-event/model/mock-calendar-events-by-workspace.ts create mode 100644 src/entities/calendar-event/ui/CalendarEventChip.tsx create mode 100644 src/features/manage-calendar/index.ts create mode 100644 src/features/manage-calendar/model/calendar-utils.ts create mode 100644 src/features/manage-calendar/model/use-calendar-events-store.ts create mode 100644 src/features/manage-calendar/ui/CalendarView.tsx create mode 100644 src/views/calendar/index.ts create mode 100644 src/views/calendar/ui/CalendarPage.tsx 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 ( +
+
+
+
+
+
+ +

+ {monthLabel} +

+ +
+ + +
+ +
+
+ {weekdayLabels.map((label, index) => ( +
+ {label} +
+ ))} +
+ +
+ {monthGrid.map((cell, index) => { + const dayEvents = eventsByDate[cell.isoDate] ?? []; + const isSelected = cell.isoDate === selectedDate; + + return ( + + ); + })} +
+
+
+ + +
+
+ + {isAddEventOpen ? ( +
+
+
+

+ {modalDateLabel} +

+ +
+ +
+
+ + + 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) => ( +
+
+ +
+ + +
+
+
+
+ ) : 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 ; +}