// 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 { 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 = [ { id: "week", label: "주" }, { id: "month", label: "월" }, { id: "day", label: "일" }, ]; function countByDate(events: CalEvent[]): Record { const m: Record = {}; events.forEach((e) => { m[e.date] = (m[e.date] || 0) + 1; }); return m; } function CalendarSkeleton() { return (
); } export function CalendarClient() { const { confirm, alert } = useDialog(); const [bundle, setBundle] = useState(null); const [err, setErr] = useState(false); const [view, setView] = useState("week"); 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); // 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")); setCalOn(loadLS>("calOn", {})); setFocusOn(loadLS("focusOn", true)); setHydrated(true); }, []); const load = () => { setErr(false); calendarApi .week() .then((b) => { setBundle(b); 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("calOn", calOn); }, [calOn, hydrated]); useEffect(() => { if (hydrated) saveLS("focusOn", focusOn); }, [focusOn, hydrated]); useEffect(() => { const el = shellRef.current; const id = requestAnimationFrame(() => el && el.classList.add("entered")); 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 (
일정을 불러오지 못했어요
); } if (!bundle) return ; 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) => setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 }); const openMeet = (ev: CalEvent) => { 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" ? `${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 (

{title}

{VIEWS.map((v) => ( ))}
{view === "week" && ( )} {view === "month" && ( )} {view === "day" && ( )}
{pop && ( 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} /> )}
); }