reopt designreopt design
DocsExploreToolsPricingBuilder
시작하기
시작하기
Playground
핵심 개념
핵심 개념
Calendar
Calendar 개요
이벤트 편집
반복 일정
타임존
예약 · 가용성
AI 스트리밍
원격 소스
앱 조합
구축·운영
스킬
릴리즈 노트
레퍼런스
Hook 레퍼런스
타입 레퍼런스
Oopt-calendar
reopt designreopt design

AI 시대를 위한 디자인 시스템

  • 문서
  • 가격
  • 릴리즈 노트
  • GitHub
  • 서비스 약관
  • 개인정보처리방침

© 2026 reopt-ai. All rights reserved.

Calendar
  1. 문서
  2. /
  3. Calendar
  4. /
  5. 예약 · 가용성

예약 · 가용성

EventType/AvailabilitySchedule/BookingSlot과 confirm→materialize 라이프사이클

reopt design · 업데이트 2026년 7월 2일

시작하기핵심 개념개요이벤트 편집반복 일정타임존예약 · 가용성AI 스트리밍원격 소스앱 조합스킬릴리즈 노트Hook 레퍼런스타입 레퍼런스

1. 예약 데이터 모델

예약 절반은 CalendarSpec의 세 맵으로 표현됩니다. eventTypes는 예약 가능한 미팅 종류, availability는 주간 가용성 스케줄, bookings는 실제 예약 레코드입니다. BookingSlot은 이 셋에서 파생되는 값으로 spec에 저장되지 않습니다.

tsx
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이 차감되며, 좌석이 소진된 슬롯만 목록에서 빠집니다.

tsx
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 히스토리를 공유합니다.

tsx
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로 바꿉니다.

tsx
// 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로 커밋됩니다.

tsx
"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" />;
}

5. 다음 단계

이벤트 편집

이벤트 CRUD, 팝오버 에디터, 드래그·리사이즈와 단일 undo 경로

AI 스트리밍

shadow draft, NDJSON 패치 스트림, 승인 전 라이브 오버레이

Previous타임존TimeZoneAdapter, wall-clock↔instant 변환, DST 경계에서의 렌더/반복 정확성Calendar
타임존 페이지로 이동
NextAI 스트리밍useCalendarSuggestion shadow draft, NDJSON 패치 스트림, 승인 전 라이브 오버레이Calendar