|
|
// 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<string, number> {
|
|
|
const m: Record<string, number> = {};
|
|
|
events.forEach((e) => {
|
|
|
m[e.date] = (m[e.date] || 0) + 1;
|
|
|
});
|
|
|
return m;
|
|
|
}
|
|
|
|
|
|
function CalendarSkeleton() {
|
|
|
return (
|
|
|
<div className="calpage">
|
|
|
<div className="twork" aria-busy="true">
|
|
|
<aside className="subnav">
|
|
|
<div className="sn-title">일정</div>
|
|
|
<div className="cal-skel-col">
|
|
|
{[0, 1, 2, 3].map((i) => (
|
|
|
<div key={i} className="cal-skel" style={{ height: 34 }} />
|
|
|
))}
|
|
|
</div>
|
|
|
</aside>
|
|
|
<main className="cmain">
|
|
|
<div className="cal-skel" style={{ height: 40, width: 240, marginBottom: 16 }} />
|
|
|
<div className="cpanel">
|
|
|
<div className="cal-skel" style={{ height: 420 }} />
|
|
|
</div>
|
|
|
</main>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export function CalendarClient() {
|
|
|
const { confirm, alert } = useDialog();
|
|
|
const [bundle, setBundle] = useState<WeekBundle | null>(null);
|
|
|
const [err, setErr] = useState(false);
|
|
|
const [view, setView] = useState<string>("week");
|
|
|
const [refDate, setRefDate] = useState<string>(""); // 네비게이션 기준일(ISO)
|
|
|
const [selDate, setSelDate] = useState<string>(""); // 일 뷰 선택일(ISO)
|
|
|
const [calOn, setCalOn] = useState<Record<string, boolean>>({});
|
|
|
const [focusOn, setFocusOn] = useState<boolean>(true);
|
|
|
const [hydrated, setHydrated] = useState(false);
|
|
|
const [pop, setPop] = useState<{ ev: CalEvent; x: number; y: number } | null>(null);
|
|
|
const [meetEv, setMeetEv] = useState<CalEvent | null>(null);
|
|
|
const [editor, setEditor] = useState<{
|
|
|
ev: CalEvent | null;
|
|
|
date?: string;
|
|
|
time?: string;
|
|
|
} | null>(null);
|
|
|
const shellRef = useRef<HTMLDivElement>(null);
|
|
|
const didMount = useRef(false);
|
|
|
|
|
|
// URL 로 열린 일정을 표현 — /calendar/<id> 의 <id> 세그먼트(옵셔널 캐치올).
|
|
|
// 주소 갱신은 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<Record<string, boolean>>("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 (
|
|
|
<div className="calpage">
|
|
|
<div className="cal-error" role="alert">
|
|
|
<Icon name="x" /> 일정을 불러오지 못했어요
|
|
|
<button onClick={load}>다시 시도</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if (!bundle) return <CalendarSkeleton />;
|
|
|
|
|
|
const today = bundle.today_date;
|
|
|
const ref = refDate || today;
|
|
|
const sel = selDate || today;
|
|
|
const weekDates = weekDatesOf(ref);
|
|
|
|
|
|
const calMap: Record<string, Calendar> = 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>): 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 (
|
|
|
<div className="calpage" ref={shellRef}>
|
|
|
<div className="twork">
|
|
|
<aside className="subnav">
|
|
|
<div className="sn-title">일정</div>
|
|
|
<button className="sn-new" onClick={() => setEditor({ ev: null })}>
|
|
|
<Icon name="plus" /> 새 일정
|
|
|
</button>
|
|
|
<MiniCal
|
|
|
monthRef={ref}
|
|
|
sel={sel}
|
|
|
today={today}
|
|
|
weekdays={bundle.weekdays}
|
|
|
eventsByDate={countByDate(events)}
|
|
|
onSel={pickDay}
|
|
|
onMonth={(dir) => setRefDate(addMonths(ref, dir))}
|
|
|
/>
|
|
|
<div className="sn-label">내 캘린더</div>
|
|
|
<CalToggleList
|
|
|
calendars={bundle.calendars}
|
|
|
calOn={calOn}
|
|
|
onToggle={(id) => setCalOn((s) => ({ ...s, [id]: s[id] === false }))}
|
|
|
/>
|
|
|
<div className="sn-foot">
|
|
|
<div className="av">지</div>
|
|
|
<div className="txt">
|
|
|
<b>지우님</b>
|
|
|
<span>Pro 플랜</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</aside>
|
|
|
|
|
|
<main className="cmain">
|
|
|
<div className="chead">
|
|
|
<button className="ch-arrow" aria-label="이전" onClick={() => nav(-1)}>
|
|
|
<Icon name="chev" className="ic flip" />
|
|
|
</button>
|
|
|
<button className="ch-arrow" aria-label="다음" onClick={() => nav(1)}>
|
|
|
<Icon name="chev" />
|
|
|
</button>
|
|
|
<h1 className="ch-title">{title}</h1>
|
|
|
<button className="ch-today" onClick={goToday}>
|
|
|
오늘
|
|
|
</button>
|
|
|
<span className="ch-spacer" />
|
|
|
<button
|
|
|
className={"ch-focus" + (focusOn ? " on" : "")}
|
|
|
onClick={() => setFocusOn((v) => !v)}
|
|
|
aria-pressed={focusOn}
|
|
|
>
|
|
|
<Icon name="spark" />
|
|
|
집중 블록
|
|
|
</button>
|
|
|
<div className="view-seg" role="tablist">
|
|
|
{VIEWS.map((v) => (
|
|
|
<button
|
|
|
key={v.id}
|
|
|
role="tab"
|
|
|
aria-selected={view === v.id}
|
|
|
className={view === v.id ? "on" : ""}
|
|
|
onClick={() => setView(v.id)}
|
|
|
>
|
|
|
{v.label}
|
|
|
</button>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{view === "week" && (
|
|
|
<WeekView
|
|
|
events={events}
|
|
|
focusBlocks={focusBlocks}
|
|
|
calMap={calMap}
|
|
|
weekDates={weekDates}
|
|
|
weekdays={bundle.weekdays}
|
|
|
todayDate={today}
|
|
|
onOpen={openEv}
|
|
|
onSlotClick={openSlot}
|
|
|
onCommit={moveEvent}
|
|
|
/>
|
|
|
)}
|
|
|
{view === "month" && (
|
|
|
<MonthView
|
|
|
events={events}
|
|
|
calMap={calMap}
|
|
|
monthRef={ref}
|
|
|
weekdays={bundle.weekdays}
|
|
|
todayDate={today}
|
|
|
onOpen={openEv}
|
|
|
onSelDay={pickDay}
|
|
|
/>
|
|
|
)}
|
|
|
{view === "day" && (
|
|
|
<DayView
|
|
|
events={events}
|
|
|
focusBlocks={focusBlocks}
|
|
|
calMap={calMap}
|
|
|
dayDate={sel}
|
|
|
todayDate={today}
|
|
|
onOpen={openEv}
|
|
|
onMeet={openMeet}
|
|
|
onSlotClick={openSlot}
|
|
|
onCommit={moveEvent}
|
|
|
/>
|
|
|
)}
|
|
|
</main>
|
|
|
</div>
|
|
|
|
|
|
{pop && (
|
|
|
<EvPopover
|
|
|
ev={pop.ev}
|
|
|
x={pop.x}
|
|
|
y={pop.y}
|
|
|
cal={calMap[pop.ev.cal]}
|
|
|
onClose={() => setPop(null)}
|
|
|
onMeet={openMeet}
|
|
|
onEdit={(ev) => {
|
|
|
setPop(null);
|
|
|
setEditor({ ev });
|
|
|
}}
|
|
|
onDelete={deleteEvent}
|
|
|
onAddToTasks={addToTasks}
|
|
|
onRsvp={rsvp}
|
|
|
/>
|
|
|
)}
|
|
|
{meetEv && (
|
|
|
<MeetDrawer ev={meetEv} cal={calMap[meetEv.cal]} onClose={() => setMeetEv(null)} />
|
|
|
)}
|
|
|
{editor && (
|
|
|
<EvEditor
|
|
|
calendars={bundle.calendars}
|
|
|
initial={editor.ev}
|
|
|
defaultDate={editor.date || sel}
|
|
|
defaultTime={editor.time}
|
|
|
onClose={() => setEditor(null)}
|
|
|
onSaved={afterWrite}
|
|
|
onDeleted={afterWrite}
|
|
|
/>
|
|
|
)}
|
|
|
</div>
|
|
|
);
|
|
|
}
|