예약 · 가용성
EventType/AvailabilitySchedule/BookingSlot과 confirm→materialize 라이프사이클
reopt design업데이트
1. 예약 데이터 모델
예약 절반은 CalendarSpec의 세 맵으로 표현됩니다. eventTypes는 예약 가능한 미팅 종류, availability는 주간 가용성 스케줄, bookings는 실제 예약 레코드입니다. BookingSlot은 이 셋에서 파생되는 값으로 spec에 저장되지 않습니다.
import type {
AvailabilitySchedule,
Booking,
EventType,
} from "@reopt-ai/opt-calendar";
// 예약 가능한 미팅 타입 — /eventTypes 맵에 저장
const sync30: EventType = {
id: "sync30",
slug: "30min",
title: "30분 싱크",
durationMinutes: 30,
availabilityId: "wk", // → /availability
calendarId: "work", // 확정 시 이벤트가 붙을 캘린더
bufferAfterMinutes: 10, // 슬롯 뒤 버퍼
minimumNoticeMinutes: 60, // 최소 사전 고지
bookingWindowDays: 30, // 예약 허용 범위
seatsPerSlot: 1, // >1이면 그룹 이벤트 (좌석 차감)
slotIntervalMinutes: 30, // 슬롯 시작 간격 (생략 시 duration)
maxBookingsPerDay: 8, // 하루 최대 접수 건수
};
// 주간 가용성 스케줄 — /availability 맵에 저장 (wall-clock "HH:mm")
const wk: AvailabilitySchedule = {
id: "wk",
name: "업무 시간",
timeZone: "Asia/Seoul", // 규칙을 해석할 타임존
rules: [
{ day: 1, start: "09:00", end: "18:00" }, // 월
{ day: 3, start: "09:00", end: "18:00" }, // 수
{ day: 5, start: "09:00", end: "13:00" }, // 금 (오전만)
],
overrides: [{ date: "2026-08-15", windows: [] }], // 휴무일
};
// 예약 레코드 — /bookings 맵에 저장 (start/end는 오프셋 포함 ISO)
const booking: Booking = {
id: "bkg_1",
eventTypeId: "sync30",
start: "2026-07-06T10:00:00+09:00",
end: "2026-07-06T10:30:00+09:00",
status: "pending", // "pending"|"confirmed"|"cancelled"|"rejected"
attendee: { name: "김하늘", email: "sky@example.com" },
};Record + materialized event. Booking은 워크플로우 레코드입니다(상태·참석자·일정 변경). 확정되면 연결된 CalendarEvent를 materialize하고 booking.eventId ↔ event.bookingId 로 상호 연결합니다. 그리드(월/주/일 뷰)는 events만 렌더하고, 예약 대시보드는 bookings를 렌더합니다.
2. 슬롯 생성 — on-demand
generateAvailabilitySlots는 EventType와 AvailabilitySchedule에서 윈도우 안의 BookingSlot[]을 필요할 때만 계산합니다. 슬롯은 그리드(events)에 절대 저장되지 않습니다. 슬롯은 durationMinutes 단위로 전진하고, 버퍼가 busy 판정 범위를 넓히며, minimumNoticeMinutes·bookingWindowDays가 경계를 제한합니다. 취소·거절된 예약은 무시됩니다. wall-clock 규칙은 주입된 TimeZoneAdapter(기본 네이티브 Intl)로 절대 시각으로 해석됩니다.
슬롯 시작 간격은 slotIntervalMinutes로 duration과 분리할 수 있고(예: 45분 미팅을 30분 간격으로), maxBookingsPerDay에 도달한 날은 통째로 제외됩니다. seatsPerSlot > 1인 그룹 타입은 같은 타입·같은 슬롯의 예약이 busy로 취급되지 않고 seatsRemaining이 차감되며, 좌석이 소진된 슬롯만 목록에서 빠집니다.
import { generateAvailabilitySlots } from "@reopt-ai/opt-calendar";
// EventType + AvailabilitySchedule에서 필요할 때만 슬롯을 계산
const slots = generateAvailabilitySlots({
eventType: sync30,
availability: wk,
from: new Date("2026-07-06"), // 윈도우 시작 (inclusive)
to: new Date("2026-07-13"), // 윈도우 끝 (exclusive)
events: Object.values(store.getSpec().events), // busy 이벤트 차감
bookings: Object.values(store.getSpec().bookings), // 기존 예약 차감
timeZone: "Asia/Seoul",
now: new Date(), // 최소 고지 / 예약 윈도우 기준
});
// → BookingSlot[] — spec에 저장되지 않는 파생 값
// [
// { eventTypeId: "sync30", start: "2026-07-06T09:00:00+09:00",
// end: "2026-07-06T09:30:00+09:00", seatsRemaining: 1 },
// ...
// ]3. 라이프사이클 — create → confirm → cancel/reschedule
라이프사이클 빌더는 모두 순수 함수로 JsonPatchOp[]를 반환하며, store.applyPatch(ops) 한 경로로 커밋되어 드래그·AI 편집과 하나의 undo 히스토리를 공유합니다.
import {
buildCreateBookingOps,
buildConfirmBookingOps,
buildCancelBookingOps,
buildRescheduleBookingOps,
generateBookingId,
} from "@reopt-ai/opt-calendar";
// 1) 생성 — pending 예약을 /bookings 맵에 추가
store.applyPatch(buildCreateBookingOps(booking), { source: "booking" });
// 2) 확정 — status를 confirmed로 바꾸고, 연결 이벤트를 materialize + 백링크
store.applyPatch(buildConfirmBookingOps(booking, sync30), { source: "booking" });
// 3) 취소/거절 — 종료 상태로 바꾸고 materialize된 이벤트 + 링크 제거
store.applyPatch(buildCancelBookingOps(booking, "cancelled"), {
source: "booking",
});
// 4) 일정 변경 — 예약과 (있다면) 연결 이벤트의 start/end를 동시 갱신
store.applyPatch(
buildRescheduleBookingOps(
booking,
"2026-07-07T10:00:00+09:00",
"2026-07-07T10:30:00+09:00",
),
{ source: "booking" },
);buildConfirmBookingOps가 만드는 패치가 materialize와 백링크를 모두 담습니다. 이미 eventId가 있으면 상태만 다시 confirmed로 바꿉니다.
// buildConfirmBookingOps(booking, sync30) 이 반환하는 패치:
[
// 1) 예약 상태를 confirmed로
{ op: "replace", path: "/bookings/bkg_1/status", value: "confirmed" },
// 2) 연결 CalendarEvent를 /events 맵에 materialize (bookingId 백링크 포함)
{
op: "add",
path: "/events/evt_1",
value: {
id: "evt_1",
title: "30분 싱크 — 김하늘", // eventType.title — attendee.name
start: "2026-07-06T10:00:00+09:00",
end: "2026-07-06T10:30:00+09:00",
status: "confirmed",
calendarId: "work", // eventType.calendarId
attendees: [
{ email: "sky@example.com", name: "김하늘", status: "accepted" },
],
bookingId: "bkg_1", // ← event.bookingId
},
},
// 3) 예약에 eventId 백링크 (booking.eventId → event.bookingId)
{ op: "add", path: "/bookings/bkg_1/eventId", value: "evt_1" },
];buildCancelBookingOps는 materialize된 이벤트와 백링크를 함께 제거하고, buildRescheduleBookingOps는 연결 이벤트의 start/end를 동기화합니다. 단발성 이벤트가 필요하면 materializeBookingEvent(booking, eventType?)로 CalendarEvent만 만들 수도 있습니다.
4. variant="booking" 뷰
<Calendar variant="booking">는 BookingView를 렌더합니다. spec의 첫 availability와 첫 eventType을 읽어 세 영역을 구성합니다 — 요일별 가용성 윈도우 편집기(요일 토글은 마지막 시간대를 기억했다가 복원, "시간 추가"는 마지막 윈도우 다음 빈 1시간을 자동 계산, copy-times로 다른 요일에 복사), Cal.com Booker식 슬롯 피커(가용일만 활성화된 월 미니 캘린더 + 선택일 시간 컬럼, 첫 가용일 자동 선택 — generateAvailabilitySlots 기반), 확정·취소·일정 변경 버튼이 달린 예약 목록. 슬롯을 고르면 예약자 폼(이름·이메일 필수, 메모 선택)이 열리고 제출 시 pending 예약이 생성됩니다. "일정 변경"을 누른 뒤 새 슬롯을 고르면 buildRescheduleBookingOps가 예약과 materialized 이벤트를 함께 옮깁니다. 가용성 편집기에는 주간 규칙 아래에 날짜 예외(date override) 편집기가 함께 있어 특정 날짜를 휴무로 닫거나 그날만 다른 시간대를 열 수 있습니다. 그룹 타입(seatsPerSlot > 1)의 슬롯 버튼에는 잔여 좌석이 표시됩니다. 모든 편집은 위의 빌더를 통해 store.applyPatch로 커밋됩니다.
"use client";
import {
Calendar,
createCalendarStore,
createEmptyCalendarSpec,
} from "@reopt-ai/opt-calendar";
import "@reopt-ai/opt-calendar/styles.css";
import { useState } from "react";
export default function BookingCalendar() {
const [store] = useState(() => {
const spec = createEmptyCalendarSpec("Asia/Seoul");
spec.availability.wk = {
id: "wk",
name: "업무 시간",
timeZone: "Asia/Seoul",
rules: [
{ day: 1, start: "09:00", end: "18:00" },
{ day: 3, start: "09:00", end: "18:00" },
{ day: 5, start: "09:00", end: "13:00" },
],
};
spec.eventTypes.sync30 = {
id: "sync30",
slug: "30min",
title: "30분 싱크",
durationMinutes: 30,
availabilityId: "wk",
calendarId: "work",
};
return createCalendarStore(spec);
});
// 슬롯 예약 → pending 예약 생성, 확정 → 이벤트로 materialize되어 그리드에 표시
return <Calendar store={store} variant="booking" timeZone="Asia/Seoul" />;
}