From ebba607f49d75064a008de5c5605861bf2d00b6e Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Fri, 19 Jun 2026 15:53:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20=EC=8B=A4=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EC=9D=BC=EC=A0=95=C2=B7=EB=B0=98=EB=B3=B5(recurrence)=C2=B7?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EC=97=90=EB=94=94=ED=84=B0=C2=B7=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8/=EB=B7=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google/Outlook 캘린더 실연동 라우터, 회의 머티리얼라이즈 갱신 - 반복 일정·RSVP·알림, EvEditor(생성/편집) 추가, 월/주/일 뷰·미니캘린더 개선 - 라우팅 [[...slug]], 관련 테스트(api_calendar·phase16·views) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/routers/calendar.py | 233 ++++++++++++++- backend/tests/test_api_calendar.py | 52 ++-- backend/tests/test_meeting_materialize.py | 36 ++- backend/tests/test_phase16_calendar.py | 137 +++++++++ frontend/app/calendar/[[...slug]]/page.tsx | 8 + frontend/app/calendar/page.tsx | 7 - .../components/calendar/CalendarClient.tsx | 282 +++++++++++++++--- frontend/components/calendar/DayView.tsx | 94 ++++-- frontend/components/calendar/EvBlock.tsx | 128 +++++++- frontend/components/calendar/EvEditor.tsx | 220 ++++++++++++++ frontend/components/calendar/EvPopover.tsx | 108 ++++++- frontend/components/calendar/FocusBlock.tsx | 1 + frontend/components/calendar/MiniCal.tsx | 47 +-- frontend/components/calendar/MonthView.tsx | 44 ++- frontend/components/calendar/WeekView.tsx | 68 +++-- frontend/lib/calendar/api.ts | 47 ++- frontend/lib/calendar/time.ts | 70 +++++ frontend/lib/calendar/types.ts | 27 ++ frontend/styles/calendar.css | 266 +++++++++++++++++ frontend/tests/calendar/views.test.tsx | 34 ++- 20 files changed, 1696 insertions(+), 213 deletions(-) create mode 100644 backend/tests/test_phase16_calendar.py create mode 100644 frontend/app/calendar/[[...slug]]/page.tsx delete mode 100644 frontend/app/calendar/page.tsx create mode 100644 frontend/components/calendar/EvEditor.tsx diff --git a/backend/app/routers/calendar.py b/backend/app/routers/calendar.py index 8e863df..a7b8642 100644 --- a/backend/app/routers/calendar.py +++ b/backend/app/routers/calendar.py @@ -1,18 +1,35 @@ # backend/app/routers/calendar.py import re import uuid +from datetime import date from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select -from ..connectors import get_calendar_connector +from ..connectors.calendar import get_calendar_connector, real_google, real_outlook +from ..connectors.calendar.normalize import account_calendar_id from ..db import get_session -from ..models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction, Task +from ..models import ( + Calendar, + CalEvent, + ConnectorAccount, + ConnectorDomain, + ConnectorMode, + ConnState, + ExternalLink, + FocusBlock, + Meeting, + MeetingAction, + Task, +) from ..schemas import ( + AttendeeOut, CalendarOut, CalEventOut, DayBundleOut, EventActionOut, + EventRsvpRequest, + EventWriteRequest, FocusBlockOut, FocusSuggestRequest, FocusSuggestResponse, @@ -30,6 +47,29 @@ router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록. WEEK = [7, 8, 9, 10, 11, 12, 13] WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"] TODAY = 8 +DEMO_TODAY_DATE = "2026-06-08" # 데모(시드) 기준일 — 실연결 없을 때 + + +def _display_date(day: int, real_date: str) -> str: + """이벤트 표시용 ISO 날짜. 실데이터는 date, 데모(시드)는 6월 day 로 매핑.""" + if real_date: + return real_date + try: + return f"2026-06-{int(day):02d}" + except (TypeError, ValueError): + return DEMO_TODAY_DATE + + +def _calendar_mode(s: Session) -> tuple[str, str]: + """(mode, today_date). real 캘린더가 연결돼 있으면 실제 오늘, 아니면 데모 기준일.""" + has_real = s.exec( + select(ConnectorAccount).where( + ConnectorAccount.domain == ConnectorDomain.calendar, + ConnectorAccount.mode == ConnectorMode.real, + ConnectorAccount.state == ConnState.connected, + ) + ).first() + return ("real", date.today().isoformat()) if has_real else ("demo", DEMO_TODAY_DATE) def _end_from(start: str, dur: int) -> str: @@ -108,6 +148,7 @@ def _event_out(s: Session, e: CalEvent) -> CalEventOut: return CalEventOut( id=e.id, day=e.day, + date=_display_date(e.day, e.date), start=e.start, end=e.end, title=e.title, @@ -116,6 +157,15 @@ def _event_out(s: Session, e: CalEvent) -> CalEventOut: note=e.note, soon=e.soon, people=[p.strip() for p in e.people.split(",") if p.strip()], + rrule=e.rrule or "", + reminders=list(e.reminders or []), + attendees=[ + AttendeeOut( + email=a.get("email", ""), name=a.get("name", ""), status=a.get("status", "") + ) + for a in (e.attendees or []) + ], + response_status=e.response_status or "", actions=[EventActionOut(text=a.text, when=a.when_text) for a in eacts], has_meeting=m is not None, meet_label=meet_label(m), @@ -124,15 +174,18 @@ def _event_out(s: Session, e: CalEvent) -> CalEventOut: @router.get("/calendar/week", response_model=WeekBundleOut) def get_week(s: Session = Depends(get_session)): - conn = get_calendar_connector() # mock-first + conn = get_calendar_connector() rows = [s.get(CalEvent, r["id"]) for r in conn.list_events(s)] cals = s.exec(select(Calendar).order_by(Calendar.sort_order)).all() counts: dict[str, int] = {} for e in rows: counts[e.cal] = counts.get(e.cal, 0) + 1 fbs = s.exec(select(FocusBlock).order_by(FocusBlock.sort_order)).all() + mode, today_date = _calendar_mode(s) return WeekBundleOut( today=TODAY, + today_date=today_date, + mode=mode, week=WEEK, weekdays=WEEKDAYS, calendars=[ @@ -140,10 +193,16 @@ def get_week(s: Session = Depends(get_session)): for c in cals ], events=[_event_out(s, e) for e in rows], - focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs], + focus_blocks=[_focus_out(f) for f in fbs], ) +def _focus_out(f: FocusBlock) -> FocusBlockOut: + d = f.model_dump() + d["date"] = _display_date(f.day, "") # 집중 블록은 데모 전용 → 6월로 매핑 + return FocusBlockOut(**d) + + @router.get("/calendar/day/{day}", response_model=DayBundleOut) def get_day(day: int, s: Session = Depends(get_session)): evs = s.exec(select(CalEvent).where(CalEvent.day == day).order_by(CalEvent.start)).all() @@ -151,7 +210,7 @@ def get_day(day: int, s: Session = Depends(get_session)): return DayBundleOut( day=day, events=[_event_out(s, e) for e in evs], - focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs], + focus_blocks=[_focus_out(f) for f in fbs], ) @@ -227,3 +286,167 @@ def focus_suggest(body: FocusSuggestRequest, s: Session = Depends(get_session)): s.add(b) s.commit() return FocusSuggestResponse(day=body.day, created=body.create, suggestions=out) + + +# ── 일정 쓰기(생성·수정·삭제). 연결된 Google 캘린더에 실제 반영 + 로컬 미러. ── +LOCAL_CAL_ID = "local" + + +def _ensure_local_calendar(s: Session) -> str: + """외부 캘린더 미연결 시 사용할 로컬 캘린더 행 보장.""" + if not s.get(Calendar, LOCAL_CAL_ID): + s.add(Calendar(id=LOCAL_CAL_ID, name="내 캘린더", tone="green", on=True, sort_order=10)) + s.commit() + return LOCAL_CAL_ID + + +_CAL_WRITERS = {"google_calendar": real_google, "outlook_calendar": real_outlook} + + +def _cal_writer(account: ConnectorAccount): + """계정 provider 에 맞는 쓰기 모듈(real_google / real_outlook).""" + return _CAL_WRITERS.get(account.provider) + + +def _writable_calendar_account(s: Session, cal_id: str = "") -> ConnectorAccount | None: + """쓰기 가능한 연결 캘린더 계정(Google·Outlook 모두 쓰기 지원). + cal_id 가 특정 캘린더를 가리키면 그 계정, 아니면 첫 연결 캘린더 계정.""" + accts = s.exec( + select(ConnectorAccount).where( + ConnectorAccount.domain == ConnectorDomain.calendar, + ConnectorAccount.state == ConnState.connected, + ) + ).all() + accts = [a for a in accts if _cal_writer(a)] # 쓰기 지원 provider 만 + if cal_id: + for a in accts: + if account_calendar_id(a) == cal_id: + return a + return accts[0] if accts else None + + +def _event_link(s: Session, eid: str) -> ExternalLink | None: + return s.exec( + select(ExternalLink).where( + ExternalLink.entity_id == eid, ExternalLink.entity_type == "event" + ) + ).first() + + +def _account_for_event(s: Session, eid: str) -> ConnectorAccount | None: + link = _event_link(s, eid) + return s.get(ConnectorAccount, link.account_id) if link else None + + +def _local_day(date_iso: str) -> int: + try: + return int(date_iso[8:10]) + except (ValueError, IndexError): + return TODAY + + +@router.post("/calendar/events", response_model=CalEventOut) +def create_event(body: EventWriteRequest, s: Session = Depends(get_session)): + acct = _writable_calendar_account(s, body.cal) + if acct: # 연결된 캘린더(Google/Outlook)에 실제 생성 후 로컬 미러 + writer = _cal_writer(acct) + ev_json = writer.create_remote_event(s, acct, body.model_dump()) + eid = writer.mirror_event(s, acct, ev_json) + s.commit() + else: # 외부 캘린더 미연결 → 로컬 전용 이벤트 + cal_id = _ensure_local_calendar(s) + eid = "ce-" + uuid.uuid4().hex[:8] + end = body.end or (_end_from(body.start, 60) if body.start else "") + s.add( + CalEvent( + id=eid, + day=_local_day(body.date), + date=body.date, + start=body.start, # 빈값 = 종일 + end=end, + title=body.title, + cal=cal_id, + loc=body.loc, + note=body.note, + people=", ".join(body.people), + rrule=body.rrule, + reminders=list(body.reminders), + attendees=[{"email": e, "name": "", "status": "needsAction"} for e in body.people], + soon=False, + sort_order=0, + ) + ) + s.commit() + return _event_out(s, s.get(CalEvent, eid)) + + +@router.patch("/calendar/events/{eid}", response_model=CalEventOut) +def update_event(eid: str, body: EventWriteRequest, s: Session = Depends(get_session)): + e = s.get(CalEvent, eid) + if not e: + raise HTTPException(404, "event not found") + acct = _account_for_event(s, eid) + writer = _cal_writer(acct) if acct else None + if acct and writer: # 연결된 캘린더(Google/Outlook)에 실제 수정 + link = _event_link(s, eid) + ev_json = writer.update_remote_event(s, acct, link.external_id, body.model_dump()) + writer.mirror_event(s, acct, ev_json) + s.commit() + else: # 로컬 전용 이벤트 + e.title = body.title + e.date = body.date + e.day = _local_day(body.date) + e.start = body.start + e.end = body.end if body.start else "" + e.loc = body.loc + e.note = body.note + e.people = ", ".join(body.people) + e.rrule = body.rrule + e.reminders = list(body.reminders) + e.attendees = [{"email": p, "name": "", "status": "needsAction"} for p in body.people] + s.add(e) + s.commit() + return _event_out(s, s.get(CalEvent, eid)) + + +@router.post("/calendar/events/{eid}/rsvp", response_model=CalEventOut) +def rsvp_event(eid: str, body: EventRsvpRequest, s: Session = Depends(get_session)): + """초대 응답(RSVP). 연결된 캘린더면 Google/Outlook 에 실제 응답 전송 + 로컬 반영.""" + if body.status not in ("accepted", "declined", "tentative"): + raise HTTPException(400, "status must be accepted|declined|tentative") + e = s.get(CalEvent, eid) + if not e: + raise HTTPException(404, "event not found") + acct = _account_for_event(s, eid) + writer = _cal_writer(acct) if acct else None + link = _event_link(s, eid) + if acct and writer and link and hasattr(writer, "respond_to_event"): + writer.respond_to_event(s, acct, link.external_id, body.status) + e.response_status = body.status + if acct: # 참석자 목록에서 내 상태도 갱신 + myemail = (acct.external_account_id or "").lower() + atts = list(e.attendees or []) + for a in atts: + if a.get("email", "").lower() == myemail: + a["status"] = body.status + e.attendees = atts + s.add(e) + s.commit() + return _event_out(s, s.get(CalEvent, eid)) + + +@router.delete("/calendar/events/{eid}") +def delete_event(eid: str, s: Session = Depends(get_session)): + e = s.get(CalEvent, eid) + if not e: + raise HTTPException(404, "event not found") + acct = _account_for_event(s, eid) + writer = _cal_writer(acct) if acct else None + link = _event_link(s, eid) + if acct and writer and link: # 연결된 캘린더(Google/Outlook)에서 실제 삭제 + writer.delete_remote_event(s, acct, link.external_id) + if link: + s.delete(link) + s.delete(e) + s.commit() + return {"ok": True, "id": eid} diff --git a/backend/tests/test_api_calendar.py b/backend/tests/test_api_calendar.py index 84867ac..5643940 100644 --- a/backend/tests/test_api_calendar.py +++ b/backend/tests/test_api_calendar.py @@ -1,45 +1,49 @@ # backend/tests/test_api_calendar.py -def test_week_bundle(client): +# phase-16+: 일정 mock 시드 제거 → 기본은 빈 상태. 기능 테스트는 직접 생성. +from tests._factories import make_cal_event, make_meeting, make_meeting_action + + +def test_week_bundle_empty(client): r = client.get("/api/calendar/week") assert r.status_code == 200 b = r.json() assert b["today"] == 8 and b["week"] == [7, 8, 9, 10, 11, 12, 13] assert b["weekdays"] == ["일", "월", "화", "수", "목", "금", "토"] - assert len(b["calendars"]) == 5 and len(b["events"]) == 22 and len(b["focus_blocks"]) == 4 - team = next(c for c in b["calendars"] if c["id"] == "team") - assert team["count"] == sum(1 for e in b["events"] if e["cal"] == "team") + # 일정 연결 전: 캘린더/이벤트/포커스 모두 비어 있음 + assert b["calendars"] == [] and b["events"] == [] and b["focus_blocks"] == [] + + +def test_week_bundle_with_event(client, session): + s, _ = session + make_cal_event(s, id="e1", day=8, start="14:00", end="15:00", title="분기 전략 미팅", + cal="work", soon=True) + b = client.get("/api/calendar/week").json() + assert any(e["id"] == "e1" for e in b["events"]) -def test_day_bundle_has_meeting_and_actions(client): +def test_day_bundle_with_meeting_and_actions(client, session): + s, _ = session + make_cal_event(s, id="e3", day=8, start="10:00", end="10:30", title="팀 스탠드업", cal="team") + make_meeting(s, event_id="e3", phase="done") + make_meeting_action(s, id="ea1", event_id="e3", idx=0, text="피드백 정리", + when_text="오늘", source="event") b = client.get("/api/calendar/day/8").json() e3 = next(e for e in b["events"] if e["id"] == "e3") assert e3["has_meeting"] is True - assert e3["meet_label"] == "회의 노트 · 액션 보기" - assert [a["text"] for a in e3["actions"]] == [ - "온보딩 와이어프레임 피드백 정리", - "푸시 알림 QA 결과 공유", - ] - e6 = next(e for e in b["events"] if e["id"] == "e6") - assert e6["soon"] is True and e6["meet_label"] == "사전 브리핑 보기" - assert e6["people"] == ["대표님", "재무팀장", "나 외 3명"] + assert [a["text"] for a in e3["actions"]] == ["피드백 정리"] -def test_meeting_phases(client): +def test_meeting_phase(client, session): + s, _ = session + make_cal_event(s, id="e3", title="팀 스탠드업", cal="team") + make_meeting(s, event_id="e3", phase="done") assert client.get("/api/calendar/meetings/e3").json()["phase"] == "done" - assert client.get("/api/calendar/meetings/e4").json()["phase"] == "live" - up = client.get("/api/calendar/meetings/e6").json() - assert up["phase"] == "upcoming" and len(up["agenda"]) == 3 and len(up["docs"]) == 2 - oo = client.get("/api/calendar/meetings/e7").json() - assert oo["one_on_one"] is True and oo["person"]["name"] == "민서" - assert len(oo["talking_points"]) == 3 def test_meeting_404(client): assert client.get("/api/calendar/meetings/nope").status_code == 404 -def test_day_focus_blocks(client): +def test_day_focus_blocks_empty(client): b = client.get("/api/calendar/day/8").json() - assert len(b["focus_blocks"]) == 4 - deep = [f for f in b["focus_blocks"] if f["type"] == "deep"] - assert len(deep) == 1 and deep[0]["title"] == "분기 리포트 초안 마무리" + assert b["focus_blocks"] == [] # 포커스 시드 제거 diff --git a/backend/tests/test_meeting_materialize.py b/backend/tests/test_meeting_materialize.py index c621296..47ec6dd 100644 --- a/backend/tests/test_meeting_materialize.py +++ b/backend/tests/test_meeting_materialize.py @@ -1,39 +1,53 @@ # backend/tests/test_meeting_materialize.py +# phase-16+: 일정 시드 제거 → 회의/액션을 테스트가 직접 생성. from app.models import Task +from tests._factories import make_cal_event, make_meeting, make_meeting_action + + +def _setup_e3(s): + make_cal_event(s, id="e3", cal="team", title="팀 스탠드업") + make_meeting(s, event_id="e3", phase="done") + make_meeting_action(s, id="ma1", meeting_id="e3", idx=2, text="매출 데이터 전달", who="현우") def test_materialize_one_creates_task(client, session): s, _ = session - # e3 idx=2 "매출 데이터 전달" (현우, added=False) + _setup_e3(s) r = client.post("/api/calendar/meetings/e3/actions/2/materialize") assert r.status_code == 200 data = r.json() assert data["action"]["added"] is True assert data["task"]["title"] == "매출 데이터 전달" - assert data["task"]["assignee_id"] == "hyunwoo" # who 나매핑 + assert data["task"]["assignee_id"] == "hyunwoo" # who '현우' → hyunwoo assert data["task"]["project_id"] == "biz" # team/work/meeting → biz - assert data["all_added"] is True # e3 의 마지막 미처리 액션이었음 - tid = data["task"]["id"] - assert s.get(Task, tid) is not None # federation: 작업 페이지에 등장 + assert data["all_added"] is True # 유일한 미처리 액션이었음 + assert s.get(Task, data["task"]["id"]) is not None -def test_materialize_idempotent(client): +def test_materialize_idempotent(client, session): + s, _ = session + _setup_e3(s) r1 = client.post("/api/calendar/meetings/e3/actions/2/materialize") t1 = r1.json()["task"]["id"] r2 = client.post("/api/calendar/meetings/e3/actions/2/materialize") assert r2.json()["task"]["id"] == t1 # 중복 생성 없음 -def test_materialize_all(client): - # e4(live) 액션 1건 모두 - r = client.post("/api/calendar/meetings/e4/actions/materialize-all") - arr = r.json() +def test_materialize_all(client, session): + s, _ = session + make_cal_event(s, id="e4", cal="work", title="디자인 리뷰") + make_meeting(s, event_id="e4", phase="live") + make_meeting_action(s, id="ea1", meeting_id="e4", idx=0, + text="온보딩 3번 화면 CTA 수정안 반영", who="나") + arr = client.post("/api/calendar/meetings/e4/actions/materialize-all").json() assert len(arr) == 1 assert arr[0]["task"]["title"] == "온보딩 3번 화면 CTA 수정안 반영" assert arr[0]["all_added"] is True -def test_materialize_action_appears_in_tasks(client): +def test_materialize_action_appears_in_tasks(client, session): + s, _ = session + _setup_e3(s) client.post("/api/calendar/meetings/e3/actions/2/materialize") tasks = client.get("/api/tasks?area=work").json() diff --git a/backend/tests/test_phase16_calendar.py b/backend/tests/test_phase16_calendar.py new file mode 100644 index 0000000..9fc7879 --- /dev/null +++ b/backend/tests/test_phase16_calendar.py @@ -0,0 +1,137 @@ +# backend/tests/test_phase16_calendar.py — phase-16 Outlook/M365 캘린더 OAuth + Graph sync +import time + +import pytest +from sqlmodel import select + +from app.config import get_settings +from app.connectors import oauth +from app.connectors.calendar import real_outlook +from app.connectors.calendar.real_outlook import OutlookCalendarConnector +from app.connectors.registry import ConnectorRegistry, _impl +from app.crypto import encrypt_token +from app.models import CalEvent, ConnectorAccount, ConnectorDomain, ConnectorMode, ConnState + + +@pytest.fixture() +def ms_creds(monkeypatch): + monkeypatch.setenv("MICROSOFT_CLIENT_ID", "m-cid") + monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "m-sec") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +class _Resp: + def __init__(self, payload, status=200): + self._p = payload + self.status_code = status + self.headers = {} + + def json(self): + return self._p + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(f"http {self.status_code}") + + +class _Client: + """httpx.Client 컨텍스트매니저 스텁 — 페이지를 순서대로 반환.""" + + def __init__(self, pages): + self._pages = list(pages) + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, url, headers=None, params=None): + self.calls.append((url, params)) + return _Resp(self._pages.pop(0)) + + +# ── OAuth: Outlook 캘린더 authorize URL (microsoft 테넌트 + Calendars.Read) ── +def test_outlook_calendar_authorize_url(session, ms_creds): + s, _ = session + url = oauth.start_oauth(s, "calendar", "outlook_calendar", redirect_after="/settings") + assert url.startswith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize") + assert "code_challenge=" in url and "state=" in url # PKCE + assert "Calendars.Read" in url and "offline_access" in url + assert "Mail.Read" not in url # 캘린더 스코프엔 메일 권한 없음 + + +# ── registry: provider 로 Google/Outlook 캘린더 분기 ── +def test_calendar_dispatch_by_provider(session): + s, _ = session + assert _impl("calendar", "real", "outlook_calendar").__name__ == "OutlookCalendarConnector" + assert _impl("calendar", "real", "google_calendar").__name__ == "GoogleCalendarConnector" + out_acct = ConnectorAccount( + id="ca-cal-o", + domain=ConnectorDomain.calendar, + provider="outlook_calendar", + mode=ConnectorMode.real, + ) + assert isinstance(ConnectorRegistry.get(s, out_acct), OutlookCalendarConnector) + + +# ── fetch: calendarView 페이지네이션(nextLink) + isCancelled 스킵 + CalEvent upsert ── +def test_outlook_calendar_fetch_paginates_and_upserts(session, monkeypatch): + s, _ = session + acct = ConnectorAccount( + id="ca-cal-outlook-1", + domain=ConnectorDomain.calendar, + provider="outlook_calendar", + mode=ConnectorMode.real, + state=ConnState.connected, + external_account_id="me@corp.com", + token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}), + ) + s.add(acct) + s.commit() + + ev = lambda i, **k: { # noqa: E731 + "id": f"ev-{i}", + "subject": f"미팅 {i}", + "start": {"dateTime": "2026-06-12T09:00:00.0000000"}, + "end": {"dateTime": "2026-06-12T09:30:00.0000000"}, + "location": {"displayName": "룸"}, + **k, + } + pages = [ + {"value": [ev(1), ev(2, isCancelled=True)], "@odata.nextLink": "https://graph/next?p=2"}, + {"value": [ev(3)]}, + ] + client = _Client(pages) + monkeypatch.setattr(real_outlook.httpx, "Client", lambda *a, **k: client) + + conn = OutlookCalendarConnector(acct) + res = conn.sync(s) + + # 취소 이벤트 제외하고 2건만 적재(ev-1, ev-3) + assert res.upserted == 2 + titles = {e.title for e in s.exec(select(CalEvent)).all()} + assert titles == {"미팅 1", "미팅 3"} + # 2번째 호출은 nextLink URL + params=None (쿼리는 nextLink 에 포함) + assert client.calls[0][0].endswith("/me/calendarView") + assert client.calls[1] == ("https://graph/next?p=2", None) + + +# ── API: /connectors/providers?domain=calendar → google + outlook 캘린더 ── +def test_calendar_providers_endpoint(client, ms_creds, monkeypatch): + monkeypatch.setenv("GOOGLE_CLIENT_ID", "g-cid") + get_settings.cache_clear() + rows = client.get("/api/connectors/providers?domain=calendar").json() + by = {r["provider"]: r for r in rows} + assert set(by) == {"google_calendar", "outlook_calendar"} + assert by["outlook_calendar"]["configured"] is True # ms_creds 설정됨 + assert by["outlook_calendar"]["label"] == "Outlook 캘린더" + + +# ── 시작 검증: 미구성 outlook_calendar 는 400 ── +def test_oauth_start_outlook_calendar_requires_microsoft_client_id(client): + r = client.get("/api/connectors/oauth/start?domain=calendar&provider=outlook_calendar") + assert r.status_code == 400 diff --git a/frontend/app/calendar/[[...slug]]/page.tsx b/frontend/app/calendar/[[...slug]]/page.tsx new file mode 100644 index 0000000..bdb6157 --- /dev/null +++ b/frontend/app/calendar/[[...slug]]/page.tsx @@ -0,0 +1,8 @@ +// frontend/app/calendar/[[...slug]]/page.tsx — 일정 페이지 (옵셔널 캐치올: /calendar, /calendar/) +// 세그먼트는 CalendarClient 가 useParams() 로 읽어 해당 일정으로 이동·오픈한다. +import "@/styles/calendar.css"; +import { CalendarClient } from "@/components/calendar/CalendarClient"; + +export default function CalendarPage() { + return ; +} diff --git a/frontend/app/calendar/page.tsx b/frontend/app/calendar/page.tsx deleted file mode 100644 index 2751219..0000000 --- a/frontend/app/calendar/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// frontend/app/calendar/page.tsx — 일정 페이지 (server component; Topbar 는 layout) -import "@/styles/calendar.css"; -import { CalendarClient } from "@/components/calendar/CalendarClient"; - -export default function CalendarPage() { - return ; -} diff --git a/frontend/components/calendar/CalendarClient.tsx b/frontend/components/calendar/CalendarClient.tsx index ccfd466..a011f0f 100644 --- a/frontend/components/calendar/CalendarClient.tsx +++ b/frontend/components/calendar/CalendarClient.tsx @@ -1,16 +1,27 @@ -// frontend/components/calendar/CalendarClient.tsx — 일정 페이지 오케스트레이터 (Topbar 제외) +// frontend/components/calendar/CalendarClient.tsx — 일정 페이지 오케스트레이터 (실제 날짜 기반) "use client"; import { useEffect, useRef, useState, type MouseEvent } from "react"; +import { useParams } from "next/navigation"; import { Icon } from "@/components/Icon"; +import { useDialog } from "@/components/Dialog"; import { calendarApi } from "@/lib/calendar/api"; import { loadLS, saveLS } from "@/lib/calendar/store"; -import type { CalEvent, WeekBundle } from "@/lib/calendar/types"; +import { + addDays, + addMonths, + dayNum, + monthNum, + weekDatesOf, + yearNum, +} from "@/lib/calendar/time"; +import type { Calendar, CalEvent, EventWrite, WeekBundle } from "@/lib/calendar/types"; import { MiniCal } from "./MiniCal"; import { CalToggleList } from "./CalToggleList"; import { WeekView } from "./WeekView"; import { MonthView } from "./MonthView"; import { DayView } from "./DayView"; import { EvPopover } from "./EvPopover"; +import { EvEditor } from "./EvEditor"; import { MeetDrawer } from "./MeetDrawer"; const VIEWS = [ @@ -19,10 +30,10 @@ const VIEWS = [ { id: "day", label: "일" }, ]; -function countByDay(events: CalEvent[]): Record { - const m: Record = {}; +function countByDate(events: CalEvent[]): Record { + const m: Record = {}; events.forEach((e) => { - m[e.day] = (m[e.day] || 0) + 1; + m[e.date] = (m[e.date] || 0) + 1; }); return m; } @@ -51,21 +62,34 @@ function CalendarSkeleton() { } export function CalendarClient() { + const { confirm, alert } = useDialog(); const [bundle, setBundle] = useState(null); const [err, setErr] = useState(false); const [view, setView] = useState("week"); - const [selDay, setSelDay] = useState(8); + const [refDate, setRefDate] = useState(""); // 네비게이션 기준일(ISO) + const [selDate, setSelDate] = useState(""); // 일 뷰 선택일(ISO) const [calOn, setCalOn] = useState>({}); const [focusOn, setFocusOn] = useState(true); const [hydrated, setHydrated] = useState(false); const [pop, setPop] = useState<{ ev: CalEvent; x: number; y: number } | null>(null); const [meetEv, setMeetEv] = useState(null); + const [editor, setEditor] = useState<{ + ev: CalEvent | null; + date?: string; + time?: string; + } | null>(null); const shellRef = useRef(null); + const didMount = useRef(false); - // 클라이언트에서만 localStorage 복원 (SSR 안정) + // URL 로 열린 일정을 표현 — /calendar/ 세그먼트(옵셔널 캐치올). + // 주소 갱신은 router 대신 history.replaceState(얕은 갱신) — router.replace 는 이 라우트를 + // 리마운트시켜 주간 네비게이션·상태가 날아가기 때문. + const params = useParams(); + const routeId = Array.isArray(params.slug) ? params.slug[0] : undefined; + + // 클라이언트에서만 localStorage 복원 (SSR 안정). 날짜는 항상 오늘에서 시작(stale 방지). useEffect(() => { setView(loadLS("view", "week")); - setSelDay(loadLS("selDay", 8)); setCalOn(loadLS>("calOn", {})); setFocusOn(loadLS("focusOn", true)); setHydrated(true); @@ -80,17 +104,32 @@ export function CalendarClient() { setCalOn((cur) => Object.keys(cur).length ? cur : Object.fromEntries(b.calendars.map((c) => [c.id, c.on])), ); + setRefDate((cur) => cur || b.today_date); + setSelDate((cur) => cur || b.today_date); }) .catch(() => setErr(true)); }; useEffect(load, []); + // 백엔드 자동 풀링이 적재한 새 일정을 주기적으로(10초)·포커스 시 반영(네비게이션 상태는 유지). + useEffect(() => { + const refetch = () => { + calendarApi + .week() + .then((b) => setBundle(b)) + .catch(() => {}); + }; + const id = setInterval(refetch, 10_000); + window.addEventListener("focus", refetch); + return () => { + clearInterval(id); + window.removeEventListener("focus", refetch); + }; + }, []); + useEffect(() => { if (hydrated) saveLS("view", view); }, [view, hydrated]); - useEffect(() => { - if (hydrated) saveLS("selDay", selDay); - }, [selDay, hydrated]); useEffect(() => { if (hydrated) saveLS("calOn", calOn); }, [calOn, hydrated]); @@ -104,6 +143,33 @@ export function CalendarClient() { return () => cancelAnimationFrame(id); }, [bundle]); + // URL → 일정: routeId 가 가리키는 일정이 있으면 그 주로 이동 + 팝오버 오픈(직접 입력·뒤로가기·딥링크). + useEffect(() => { + if (!bundle) return; // 번들 로드 후에만 탐색 가능 + if (!routeId) { + setPop((p) => (p ? null : p)); // 주소에서 빠지면 팝오버 닫기 + return; + } + if (pop?.ev.id === routeId) return; // 이미 열려 있음 + const ev = bundle.events.find((e) => e.id === routeId); + if (!ev) return; // 없는 id → 무시(잘못된 주소) + setRefDate(ev.date); + setSelDate(ev.date); + setPop({ ev, x: Math.max(window.innerWidth / 2 - 150, 16), y: 140 }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [routeId, bundle]); + + // 일정 → URL: 팝오버로 연 일정에 주소를 맞춘다(초기 마운트는 위 effect 가 처리하므로 skip). + useEffect(() => { + if (!didMount.current) { + didMount.current = true; + return; + } + const id = pop?.ev.id; + const target = id ? `/calendar/${id}` : "/calendar"; + if (window.location.pathname !== target) window.history.replaceState(null, "", target); + }, [pop]); + if (err && !bundle) { return (
@@ -117,7 +183,20 @@ export function CalendarClient() { if (!bundle) return ; - const calMap = Object.fromEntries(bundle.calendars.map((c) => [c.id, c])); + const today = bundle.today_date; + const ref = refDate || today; + const sel = selDate || today; + const weekDates = weekDatesOf(ref); + + const calMap: Record = Object.fromEntries( + bundle.calendars.map((c) => [c.id, c]), + ); + // 안전망: 이벤트가 참조하는 캘린더가 목록에 없어도 렌더가 깨지지 않도록 기본값 보강. + for (const e of bundle.events) { + if (!calMap[e.cal]) { + calMap[e.cal] = { id: e.cal, name: e.cal || "기타", tone: "ink", on: true, count: 0 }; + } + } const events = bundle.events.filter((e) => calOn[e.cal] !== false); const focusBlocks = focusOn ? bundle.focus_blocks : []; const openEv = (ev: CalEvent, e: MouseEvent) => @@ -126,26 +205,130 @@ export function CalendarClient() { setPop(null); setMeetEv(ev); }; + const afterWrite = () => { + setEditor(null); + setPop(null); + load(); + }; + const deleteEvent = async (ev: CalEvent) => { + setPop(null); + const ok = await confirm({ + title: "일정 삭제", + message: `‘${ev.title}’ 일정을 삭제할까요?`, + confirmText: "삭제", + tone: "danger", + }); + if (!ok) return; + try { + await calendarApi.deleteEvent(ev.id); + load(); + } catch (e) { + alert({ title: "삭제 실패", message: e instanceof Error ? e.message : "삭제하지 못했어요" }); + } + }; + // 빈 시간 슬롯 클릭 → 해당 날짜·시각으로 생성 모달 오픈. + const openSlot = (date: string, time: string) => + setEditor({ ev: null, date, time }); + // 팝오버 "내 작업에 추가" → 회의 액션 전체를 작업으로 변환. + const addToTasks = async (ev: CalEvent) => { + await calendarApi.materializeAll(ev.id); + }; + // 드래그 이동/리사이즈 → 날짜·시각 변경을 서버에 반영(낙관적 갱신 후 재로드). + const evToWrite = (ev: CalEvent, patch: Partial): EventWrite => ({ + title: ev.title, + date: ev.date, + start: ev.start, + end: ev.end, + loc: ev.loc, + note: ev.note, + people: ev.people, + cal: ev.cal, + rrule: ev.rrule, + reminders: ev.reminders, + ...patch, + }); + const moveEvent = (ev: CalEvent, patch: { date: string; start: string; end: string }) => { + setBundle( + (b) => + b && { + ...b, + events: b.events.map((e) => + e.id === ev.id + ? { ...e, ...patch, day: Number(patch.date.slice(8, 10)) || e.day } + : e, + ), + }, + ); + calendarApi + .updateEvent(ev.id, evToWrite(ev, patch)) + .then(load) + .catch((e) => { + alert({ + title: "이동 실패", + message: e instanceof Error ? e.message : "일정을 옮기지 못했어요", + }); + load(); + }); + }; + // 초대 응답(RSVP) → 서버 반영 + 팝오버 즉시 갱신. + const rsvp = async (ev: CalEvent, status: "accepted" | "declined" | "tentative") => { + try { + const updated = await calendarApi.rsvp(ev.id, status); + setPop((p) => (p && p.ev.id === ev.id ? { ...p, ev: updated } : p)); + load(); + } catch (e) { + alert({ + title: "응답 실패", + message: e instanceof Error ? e.message : "응답을 보내지 못했어요", + }); + } + }; + + const nav = (dir: number) => { + if (view === "month") setRefDate(addMonths(ref, dir)); + else if (view === "day") { + const nd = addDays(sel, dir); + setSelDate(nd); + setRefDate(nd); + } else setRefDate(addDays(ref, dir * 7)); + }; + const goToday = () => { + setRefDate(today); + setSelDate(today); + }; + const pickDay = (iso: string) => { + setSelDate(iso); + setRefDate(iso); + setView("day"); + }; + + const ws = weekDates[0]; + const we = weekDates[6]; const title = - view === "month" ? "2026년 6월" : view === "day" ? `6월 ${selDay}일` : "6월 7일 – 13일"; + view === "month" + ? `${yearNum(ref)}년 ${monthNum(ref)}월` + : view === "day" + ? `${monthNum(sel)}월 ${dayNum(sel)}일` + : monthNum(ws) === monthNum(we) + ? `${monthNum(ws)}월 ${dayNum(ws)}일 – ${dayNum(we)}일` + : `${monthNum(ws)}월 ${dayNum(ws)}일 – ${monthNum(we)}월 ${dayNum(we)}일`; return (
-
-
- -
-

- 오늘 일정 중 14:00 분기 전략 미팅이 가장 중요해요. 미팅 사이 빈 시간엔 가벼운 - 일을, 오후 15:10 빈 블록엔 ‘분기 리포트’ 딥 워크를 자동으로 배치해뒀어요. 16:00 - ‘치과 예약’은 1:1과 가까워 15분 앞당기길 추천드려요. -

-
- {view === "week" && ( )} {view === "month" && ( { - setSelDay(n); - setView("day"); - }} + onSelDay={pickDay} /> )} {view === "day" && ( @@ -238,12 +410,12 @@ export function CalendarClient() { events={events} focusBlocks={focusBlocks} calMap={calMap} - day={selDay} - week={bundle.week} - weekdays={bundle.weekdays} - today={bundle.today} + dayDate={sel} + todayDate={today} onOpen={openEv} onMeet={openMeet} + onSlotClick={openSlot} + onCommit={moveEvent} /> )} @@ -257,11 +429,29 @@ export function CalendarClient() { cal={calMap[pop.ev.cal]} onClose={() => setPop(null)} onMeet={openMeet} + onEdit={(ev) => { + setPop(null); + setEditor({ ev }); + }} + onDelete={deleteEvent} + onAddToTasks={addToTasks} + onRsvp={rsvp} /> )} {meetEv && ( setMeetEv(null)} /> )} + {editor && ( + setEditor(null)} + onSaved={afterWrite} + onDeleted={afterWrite} + /> + )}
); } diff --git a/frontend/components/calendar/DayView.tsx b/frontend/components/calendar/DayView.tsx index 913073f..09f6881 100644 --- a/frontend/components/calendar/DayView.tsx +++ b/frontend/components/calendar/DayView.tsx @@ -1,44 +1,54 @@ -// frontend/components/calendar/DayView.tsx — 일 뷰 (그리드 + 집중 카드 + 어젠다) +// frontend/components/calendar/DayView.tsx — 일 뷰 (실제 날짜 기반: 그리드 + 집중 카드 + 어젠다) "use client"; import type { MouseEvent } from "react"; import { Icon } from "@/components/Icon"; -import { HOURS, toMin, topOf } from "@/lib/calendar/time"; +import { HOURS, dayNum, monthNum, parseISO, timeOfPx, toMin, topOf } from "@/lib/calendar/time"; import type { CalEvent, Calendar, FocusBlock as FocusBlockT } from "@/lib/calendar/types"; -import { EvBlock } from "./EvBlock"; +import { EvBlock, type EvCommit } from "./EvBlock"; import { FocusBlock } from "./FocusBlock"; const PEOPLE_COLORS = ["var(--coral)", "var(--blue)", "var(--violet)"]; +const WEEKDAYS_KO = ["일", "월", "화", "수", "목", "금", "토"]; + +function evSort(a: CalEvent, b: CalEvent): number { + // 종일 먼저, 그다음 시작시각 순 + if (!a.start && b.start) return -1; + if (a.start && !b.start) return 1; + if (!a.start && !b.start) return 0; + return toMin(a.start) - toMin(b.start); +} export function DayView({ events, focusBlocks, calMap, - day, - week, - weekdays, - today, + dayDate, + todayDate, onOpen, onMeet, + onSlotClick, + onCommit, }: { events: CalEvent[]; focusBlocks: FocusBlockT[]; calMap: Record; - day: number; - week: number[]; - weekdays: string[]; - today: number; + dayDate: string; + todayDate: string; onOpen: (ev: CalEvent, e: MouseEvent) => void; onMeet: (ev: CalEvent) => void; + onSlotClick: (dateIso: string, time: string) => void; + onCommit?: EvCommit; }) { - const dayEvents = events - .filter((e) => e.day === day) - .sort((a, b) => toMin(a.start) - toMin(b.start)); + const dayEvents = events.filter((e) => e.date === dayDate).sort(evSort); const dayFocus = focusBlocks - .filter((b) => b.day === day) + .filter((b) => b.date === dayDate) .sort((a, b) => toMin(a.start) - toMin(b.start)); - const idx = week.indexOf(day); - const wd = idx >= 0 ? weekdays[idx] : weekdays[new Date(2026, 5, day).getDay()]; - const nowTop = topOf("11:10"); + const wd = WEEKDAYS_KO[parseISO(dayDate).getDay()]; + const isToday = dayDate === todayDate; + const now = new Date(); + const nowTop = topOf( + `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`, + ); return (
@@ -51,17 +61,37 @@ export function DayView({
))}
-
+
{ + const rect = e.currentTarget.getBoundingClientRect(); + onSlotClick(dayDate, timeOfPx(e.clientY - rect.top)); + }} + > {HOURS.map((h) => (
))} - {day === today &&
} + {isToday &&
} {dayFocus.map((b) => ( ))} - {dayEvents.map((ev) => ( - - ))} + {dayEvents + .filter((e) => !e.start) + .map((ev, ai) => ( + + ))} + {dayEvents + .filter((e) => e.start) + .map((ev) => ( + + ))}
@@ -96,22 +126,28 @@ export function DayView({
- 6월 {day}일 {wd}요일 + {monthNum(dayDate)}월 {dayNum(dayDate)}일 {wd}요일
- 일정 {dayEvents.length}개{day === today ? " · 오늘" : ""} + 일정 {dayEvents.length}개{isToday ? " · 오늘" : ""}
{dayEvents.length === 0 &&
이 날은 일정이 없어요.
} {dayEvents.map((ev) => (
onOpen(ev, e)} >
- {ev.start} - {ev.end} + {ev.start ? ( + <> + {ev.start} + {ev.end} + + ) : ( + "종일" + )}
@@ -129,7 +165,7 @@ export function DayView({ display: "inline-block", }} />{" "} - {calMap[ev.cal].name} + {calMap[ev.cal]?.name ?? "기타"} {ev.loc && ( <> diff --git a/frontend/components/calendar/EvBlock.tsx b/frontend/components/calendar/EvBlock.tsx index 14a8548..cfb61f2 100644 --- a/frontend/components/calendar/EvBlock.tsx +++ b/frontend/components/calendar/EvBlock.tsx @@ -1,34 +1,132 @@ -// frontend/components/calendar/EvBlock.tsx — 주/일 그리드의 이벤트 블록 +// frontend/components/calendar/EvBlock.tsx — 주/일 그리드의 이벤트 블록(드래그 이동·리사이즈) "use client"; -import type { MouseEvent } from "react"; -import { toMin, topOf, PXH } from "@/lib/calendar/time"; +import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; +import { GRID_END_MIN, HSTART, PXH, fromMin, toMin, topOf } from "@/lib/calendar/time"; import type { CalEvent, Calendar } from "@/lib/calendar/types"; +export type EvCommit = ( + ev: CalEvent, + patch: { date: string; start: string; end: string }, +) => void; + export function EvBlock({ ev, cal, onClick, + idx = 0, + onCommit, }: { ev: CalEvent; cal: Calendar; - onClick: (ev: CalEvent, e: MouseEvent) => void; + onClick: (ev: CalEvent, e: ReactMouseEvent) => void; + idx?: number; // 종일 일정 세로 스택 인덱스 + onCommit?: EvCommit; // 드래그 이동/리사이즈 확정(없으면 드래그 비활성) }) { - const top = topOf(ev.start); - const h = ((toMin(ev.end) - toMin(ev.start)) / 60) * PXH; - const short = h < 38; + // 종일(시작 시각 없음) 일정은 그리드 상단에 22px 간격으로 쌓는다(NaN 위치 방지). + const allDay = !ev.start; + const tone = cal?.tone ?? "ink"; // 캘린더 누락 시에도 안전 + const baseTop = allDay ? idx * 22 : topOf(ev.start); + const baseH = allDay ? 20 : ((toMin(ev.end) - toMin(ev.start)) / 60) * PXH; + const short = allDay || baseH < 38; + const canDrag = !allDay && !!onCommit; + + const [active, setActive] = useState(false); + const [vis, setVis] = useState<{ mode: "move" | "resize"; dy: number }>({ mode: "move", dy: 0 }); + const dragRef = useRef<{ mode: "move" | "resize"; startX: number; startY: number; moved: boolean } | null>(null); + const suppressClick = useRef(false); + + useEffect(() => { + if (!active) return; + const onMove = (e: MouseEvent) => { + const d = dragRef.current; + if (!d) return; + const dy = e.clientY - d.startY; + if (Math.abs(dy) > 3 || Math.abs(e.clientX - d.startX) > 3) d.moved = true; + setVis({ mode: d.mode, dy }); + }; + const onUp = (e: MouseEvent) => { + const d = dragRef.current; + dragRef.current = null; + setActive(false); + if (!d || !d.moved || !onCommit) return; + suppressClick.current = true; // 드래그 직후의 click 으로 팝오버가 열리지 않도록 + const dy = e.clientY - d.startY; + const deltaMin = Math.round((dy / PXH) * 60 / 15) * 15; // 15분 스냅 + const startMin = toMin(ev.start); + const endMin = toMin(ev.end); + const dur = endMin - startMin; + if (d.mode === "move") { + let ns = startMin + deltaMin; + ns = Math.max(HSTART * 60, Math.min(ns, GRID_END_MIN - dur)); + // 날짜 변경(주 뷰): 포인터 아래 컬럼의 data-date 로 이동 + let date = ev.date; + const under = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; + const col = under?.closest("[data-date]") as HTMLElement | null; + if (col?.dataset.date) date = col.dataset.date; + if (ns === startMin && date === ev.date) return; + onCommit(ev, { date, start: fromMin(ns), end: fromMin(ns + dur) }); + } else { + let ne = endMin + deltaMin; + ne = Math.max(startMin + 15, Math.min(ne, GRID_END_MIN)); + if (ne === endMin) return; + onCommit(ev, { date: ev.date, start: ev.start, end: fromMin(ne) }); + } + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [active, ev, onCommit]); + + const beginDrag = (mode: "move" | "resize", e: ReactMouseEvent) => { + if (!canDrag) return; + e.stopPropagation(); + e.preventDefault(); + dragRef.current = { mode, startX: e.clientX, startY: e.clientY, moved: false }; + setVis({ mode, dy: 0 }); + setActive(true); + }; + + let top = baseTop; + let h = baseH; + if (active && vis.mode === "move") top = baseTop + vis.dy; + if (active && vis.mode === "resize") h = Math.max(baseH + vis.dy, 20); + + const handleClick = (e: ReactMouseEvent) => { + e.stopPropagation(); + if (suppressClick.current) { + suppressClick.current = false; + return; + } + onClick(ev, e); + }; + return (
{ - e.stopPropagation(); - onClick(ev, e); - }} + onMouseDown={canDrag ? (e) => beginDrag("move", e) : undefined} + onClick={handleClick} >
{ev.title}
-
- {ev.start}–{ev.end} -
+ {!allDay && ( +
+ {ev.start}–{ev.end} +
+ )} + {canDrag && ( +
beginDrag("resize", e)} aria-hidden /> + )}
); } diff --git a/frontend/components/calendar/EvEditor.tsx b/frontend/components/calendar/EvEditor.tsx new file mode 100644 index 0000000..fe307ca --- /dev/null +++ b/frontend/components/calendar/EvEditor.tsx @@ -0,0 +1,220 @@ +// frontend/components/calendar/EvEditor.tsx — 새 일정 생성/편집 모달 +"use client"; +import { useState } from "react"; +import { Icon } from "@/components/Icon"; +import { calendarApi } from "@/lib/calendar/api"; +import { addHour } from "@/lib/calendar/time"; +import type { Calendar, CalEvent, EventWrite } from "@/lib/calendar/types"; + +// 쓰기 가능한 캘린더(Google = gcal-*, Outlook = ocal-*, 로컬 = local). +function writableCalendars(cals: Calendar[]): Calendar[] { + const ok = cals.filter( + (c) => c.id.startsWith("gcal-") || c.id.startsWith("ocal-") || c.id === "local", + ); + return ok.length ? ok : cals; +} + +export function EvEditor({ + calendars, + initial, + defaultDate, + defaultTime, + onClose, + onSaved, + onDeleted, +}: { + calendars: Calendar[]; + initial?: CalEvent | null; // 있으면 편집, 없으면 생성 + defaultDate: string; // 생성 시 기본 날짜(ISO) + defaultTime?: string; // 생성 시 기본 시작시각("HH:MM", 슬롯 클릭) + onClose: () => void; + onSaved: () => void; + onDeleted?: () => void; +}) { + const editing = !!initial; + const writable = writableCalendars(calendars); + const [title, setTitle] = useState(initial?.title ?? ""); + const [date, setDate] = useState(initial?.date || defaultDate); + const [allDay, setAllDay] = useState(editing ? !initial?.start : false); + const [start, setStart] = useState(initial?.start || defaultTime || "09:00"); + const [end, setEnd] = useState( + initial?.end || (defaultTime ? addHour(defaultTime, 1) : "10:00"), + ); + const [loc, setLoc] = useState(initial?.loc ?? ""); + const [note, setNote] = useState(initial?.note ?? ""); + const [cal, setCal] = useState(initial?.cal || writable[0]?.id || ""); + const [people, setPeople] = useState((initial?.people ?? []).join(", ")); + const [rrule, setRrule] = useState(initial?.rrule ?? ""); + const [reminder, setReminder] = useState(initial?.reminders?.[0] ?? -1); // -1 = 없음 + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + const save = async () => { + if (!title.trim()) { + setErr("제목을 입력하세요"); + return; + } + setBusy(true); + setErr(""); + const body: EventWrite = { + title: title.trim(), + date, + start: allDay ? "" : start, + end: allDay ? "" : end, + loc: loc.trim(), + note: note.trim(), + people: people + .split(",") + .map((p) => p.trim()) + .filter(Boolean), + cal, + rrule, + reminders: reminder >= 0 ? [reminder] : [], + }; + try { + if (editing && initial) await calendarApi.updateEvent(initial.id, body); + else await calendarApi.createEvent(body); + onSaved(); + } catch (e) { + setErr(e instanceof Error ? e.message : "저장하지 못했어요"); + setBusy(false); + } + }; + + const del = async () => { + if (!initial) return; + setBusy(true); + setErr(""); + try { + await calendarApi.deleteEvent(initial.id); + onDeleted?.(); + } catch (e) { + setErr(e instanceof Error ? e.message : "삭제하지 못했어요"); + setBusy(false); + } + }; + + return ( + <> +
+
+
+

{editing ? "일정 편집" : "새 일정"}

+ +
+ + + + + + + + {!allDay && ( +
+ + +
+ )} + + + + + +
+ + +
+ + + +