예약 일정

@dayflow-pro/appointment-schedule은 두 가지 일을 합니다:

  1. 주최자가 언제 예약을 받을 수 있는지 정의하도록 해 줍니다. DayFlow 주 뷰에서 바로 편집합니다.
  2. 그 정의를 참석자가 선택할 수 있는 슬롯으로 바꿔 줍니다. CalendarApp은 필요하지 않습니다.

이것은 예약 플랫폼이 아닙니다. 백엔드도, 예약 생명주기도, 알림 서비스도, 결제 시스템도 없습니다. 슬롯을 선택하면 콜백이 실행되고, 그다음은 애플리케이션이 이어받습니다.

설치

npm install @dayflow-pro/appointment-schedule
pnpm add @dayflow-pro/appointment-schedule
yarn add @dayflow-pro/appointment-schedule
bun add @dayflow-pro/appointment-schedule

설치 단계는 Pro 설치 가이드를 참고하세요.

temporal-polyfill은 필수입니다. @dayflow/core는 주최자 플러그인과 주간 오버레이 레이아웃에서만 필요합니다. React, Vue, Svelte, Angular는 선택적 peer 의존성이므로, 사용하려는 어댑터의 프레임워크만 설치하면 됩니다.

import '@dayflow-pro/appointment-schedule/styles.css';
// Alternatively, for Tailwind projects that already load the core theme:
import '@dayflow-pro/appointment-schedule/styles.components.css';

주최자 플러그인

appointmentPlugin.ts
import { createAppointmentSchedulePlugin } from '@dayflow-pro/appointment-schedule/plugin';
import type { AppointmentSchedule } from '@dayflow-pro/appointment-schedule/engine';
import '@dayflow-pro/appointment-schedule/styles.css';

let schedules: AppointmentSchedule[] = [];

const upsertSchedule = (schedule: AppointmentSchedule) => {
  schedules = [
    ...schedules.filter(current => current.id !== schedule.id),
    schedule,
  ];
  appointmentPlugin.updateConfig?.({ schedules });
};

export const appointmentPlugin = createAppointmentSchedulePlugin({
  schedules,
  drawerPlacement: 'calendar',
  drawerWidth: 420,
  onCreateSchedule: upsertSchedule,
  onUpdateSchedule: upsertSchedule,
});

프레임워크의 주 뷰에 동일한 플러그인 인스턴스를 추가하세요:

import { createWeekView } from '@dayflow/core';
import { DayFlowCalendar, useCalendarApp } from '@dayflow/react';
import { appointmentPlugin } from './appointmentPlugin';

export function App() {
  const calendar = useCalendarApp({
    views: [createWeekView()],
    plugins: [appointmentPlugin],
  });

  return (
    <>
      <button onClick={() => appointmentPlugin.api.openCreate()}>
        New appointment schedule
      </button>
      <DayFlowCalendar calendar={calendar} />
    </>
  );
}
<script setup lang="ts">
import { createWeekView } from '@dayflow/core';
import { DayFlowCalendar, useCalendarApp } from '@dayflow/vue';
import { appointmentPlugin } from './appointmentPlugin';

const calendar = useCalendarApp({
  views: [createWeekView()],
  plugins: [appointmentPlugin],
});
</script>

<template>
  <button @click="appointmentPlugin.api.openCreate()">
    New appointment schedule
  </button>
  <DayFlowCalendar :calendar="calendar" />
</template>
import { Component } from '@angular/core';
import { DayFlowCalendarModule } from '@dayflow/angular';
import { createWeekView } from '@dayflow/core';
import { appointmentPlugin } from './appointmentPlugin';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [DayFlowCalendarModule],
  template: `
    <button (click)="appointmentPlugin.api.openCreate()">
      New appointment schedule
    </button>
    <dayflow-calendar [calendar]="calendar"></dayflow-calendar>
  `,
})
export class AppComponent {
  readonly appointmentPlugin = appointmentPlugin;
  readonly calendar = {
    views: [createWeekView()],
    plugins: [appointmentPlugin],
  };
}
<script lang="ts">
  import { createWeekView } from '@dayflow/core';
  import { DayFlowCalendar, useCalendarApp } from '@dayflow/svelte';
  import { appointmentPlugin } from './appointmentPlugin';

  const calendar = useCalendarApp({
    views: [createWeekView()],
    plugins: [appointmentPlugin],
  });
</script>

<button onclick={() => appointmentPlugin.api.openCreate()}>
  New appointment schedule
</button>
<DayFlowCalendar {calendar} />

schedules는 제어되는 데이터로, 플러그인은 아무것도 저장하지 않습니다. 가용 시간은 이벤트 아래의 배경 레이어로 그려지므로 getEvents(), 검색, 인쇄, 원격 동기화에는 전혀 포함되지 않습니다.

일정 데이터 모델

AppointmentSchedule은 주최자 화면, 예약 컴포넌트, 헤드리스 엔진이 공유하는 데이터 계약입니다.

속성타입용도
idstring일정의 고정 식별자입니다.
titlestring주최자 화면과 예약 화면에 표시되는 이름입니다.
durationMinutesnumber각 예약의 길이입니다.
slotIntervalMinutes?number슬롯 시작 시각 사이의 간격입니다. 기본값은 예약 길이와 같습니다.
beforeBufferMinutes?number각 예약 앞에 두는 여유 시간입니다. 기본값은 0입니다.
afterBufferMinutes?number각 예약 뒤에 두는 여유 시간입니다. 기본값은 0입니다.
timeZonestring가용 시간을 정의하는 기준 IANA 시간대입니다.
calendarId?string색상과 생성되는 이벤트에 사용할 호스트 캘린더입니다.
recurrence?AppointmentRecurrence매주, 일회성, 또는 N주마다 반복하는 규칙입니다.
availabilityWeeklyAvailability[]요일별로 묶인 반복 예약 가능 구간입니다. 필수입니다.
unavailableIntervals?WeeklyAvailability[]화면에는 보이지만 슬롯을 만들지 않는 반복 휴식 시간입니다.
dateOverrides?DateAvailabilityOverride[]주간 규칙을 대체하는 날짜별 구간입니다.
location?AppointmentLocationConfig직접 지정한 링크, 화상회의 제공자, 주소 또는 전화번호입니다.
meta?Record<string, unknown>애플리케이션이 소유하는 직렬화 가능한 메타데이터입니다.

WeeklyAvailability에는 dayOfWeek(0 일요일 ~ 6 토요일)와 intervals 배열이 들어갑니다. 각 AvailabilityIntervalid와, 벽시계 기준 HH:mm 형식의 startTime·endTime을 가집니다. DateAvailabilityOverride는 ISO 형식의 date와 대체할 intervals로 구성되며, 빈 배열을 넣으면 그날은 예약을 받지 않습니다.

직접 만든 UI에서 편집기를 여세요:

appointmentPlugin.api.openCreate();
appointmentPlugin.api.openEdit('product-demo');

이 플러그인은 캘린더의 빠른 생성 팝업에도 항목을 추가합니다. 플러그인이 없는 캘린더는 기존의 빠른 생성 화면을 그대로 유지합니다.

자주 쓰는 옵션

다음 필드들이 AppointmentSchedulePluginConfig를 구성합니다.

옵션기본값용도
schedules필수제어되는 예약 일정 목록입니다.
activeScheduleId없음특정 일정을 선택한 상태로 편집기를 엽니다.
availabilitySnapMinutes15가용 시간을 편집할 때 사용하는 분 단위 간격입니다.
drawerPlacement'viewport'편집기를 뷰포트 또는 캘린더에 붙입니다.
drawerWidth420드로어의 너비입니다. 픽셀 또는 임의의 CSS 길이를 지정합니다.
drawerTarget첫 번째 캘린더calendar 배치에서 사용할 요소 또는 선택자입니다.
drawerRenderer기본 드로어주최자 편집기를 통째로 교체합니다.
timeFormat활성 뷰의 형식12시간제 또는 24시간제를 사용합니다.
conferenceProviders[]위치 선택기에 이름이 지정된 화상회의 제공자를 추가합니다.
onCreateSchedule없음새로 만든 일정을 애플리케이션에 저장합니다.
onUpdateSchedule없음기존 일정의 변경 사항을 저장합니다.
onDeleteScheduleRequest없음호스트 앱에 일정 삭제 확인과 처리를 요청합니다.
onExternalUpdateConflict없음편집 중에 도착한 제어 데이터 갱신을 알립니다.

플러그인 API

플러그인은 appointmentPlugin.apiAppointmentScheduleApi를 노출합니다.

메서드용도
openCreate(initial?)초기 필드를 선택적으로 지정해 새 초안을 엽니다.
openEdit(scheduleId)기존의 제어되는 일정을 엽니다.
closeEditor() / cancelDraft()현재 초안을 버리고 드로어를 닫습니다.
saveDraft()생성 또는 수정 콜백을 실행하고, 성공하면 닫습니다.
setActiveSchedule(scheduleId)편집기를 열지 않고 활성 일정만 바꿉니다.
getActiveSchedule()현재 활성화된 제어 일정을 반환합니다.
getDraft()편집 중이라면 현재 수정 가능한 초안을 반환합니다.
draftManager필드와 가용 시간을 편집하는 동작을 제공합니다.
subscribeDraft(listener)초안 변경을 구독하고 구독 해제 함수를 반환합니다.

주최자 편집기 커스터마이징

drawerRenderer로 주최자 드로어 전체를 교체할 수 있습니다. 배치, 활성 초안, 캘린더에서의 가용 시간 편집, 저장·취소 동작은 여전히 플러그인이 담당합니다. 애플리케이션은 전달받은 호스트 안에 평범한 프레임워크 컴포넌트를 렌더링하면 됩니다.

렌더러는 AppointmentScheduleDrawerRenderArgs를 전달받습니다:

속성용도
draft현재 AppointmentSchedule 초안입니다.
isCreating새 일정인지 기존 일정 수정인지 구분합니다.
draftManager필드를 갱신하고 toggleDay, addInterval, updateInterval, removeInterval 및 복사 헬퍼를 제공합니다.
calendars선택 가능한 캘린더 목록입니다({ id, name, color? } 형태).
conferenceProviders등록된 제공자 목록입니다({ id, name, icon? } 형태).
placement확정된 배치 값입니다: 'calendar' 또는 'viewport'.
drawerWidth확정된 CSS 너비 문자열입니다.
timeFormat / locale설정과 캘린더에서 물려받은 표시 환경설정입니다.
translate패키지 번역을 조회하며, 없으면 대체 문구를 사용합니다.
save()호스트의 생성 또는 수정 콜백을 실행하고, 성공하면 닫습니다.
cancel()초안을 버리고 편집기를 닫습니다.

이 콜백은 프레임워크를 마운트하는 경계일 뿐, DOM을 직접 조작해 폼을 만들라는 뜻이 아닙니다. 아래 예시는 제목·저장·취소 컨트롤을 각 프레임워크의 네이티브 컴포넌트로 동일하게 렌더링합니다:

organiserDrawer.tsx
import { createRoot } from 'react-dom/client';
import type {
  AppointmentScheduleDrawerRenderArgs,
  AppointmentScheduleDrawerRenderer,
} from '@dayflow-pro/appointment-schedule/plugin';

function OrganiserDrawer({
  args,
}: {
  args: AppointmentScheduleDrawerRenderArgs;
}) {
  return (
    <form onSubmit={event => { event.preventDefault(); void args.save(); }}>
      <input
        value={args.draft.title}
        onChange={event =>
          args.draftManager.updateDraft({ title: event.target.value })
        }
      />
      <button type="submit">Save</button>
      <button type="button" onClick={args.cancel}>Cancel</button>
    </form>
  );
}

export const drawerRenderer: AppointmentScheduleDrawerRenderer =
  (initial, host) => {
    const root = createRoot(host);
    const render = (args: AppointmentScheduleDrawerRenderArgs) =>
      root.render(<OrganiserDrawer args={args} />);

    render(initial);
    // Closing the drawer from a React effect — flipping `drawerRenderer`,
    // for instance — reaches `destroy` while React is still rendering, and
    // unmounting a root there races that render. Defer it by a microtask.
    return {
      update: render,
      destroy: () => queueMicrotask(() => root.unmount()),
    };
  };
OrganiserDrawer.vue
<script setup lang="ts">
import type { AppointmentScheduleDrawerRenderArgs } from '@dayflow-pro/appointment-schedule/plugin';

const props = defineProps<{ args: AppointmentScheduleDrawerRenderArgs }>();
const updateTitle = (event: Event) => {
  props.args.draftManager.updateDraft({
    title: (event.target as HTMLInputElement).value,
  });
};
</script>

<template>
  <form @submit.prevent="args.save()">
    <input :value="args.draft.title" @input="updateTitle" />
    <button type="submit">Save</button>
    <button type="button" @click="args.cancel()">Cancel</button>
  </form>
</template>
organiserDrawerRenderer.ts
import { createApp, h, reactive } from 'vue';
import type {
  AppointmentScheduleDrawerRenderArgs,
  AppointmentScheduleDrawerRenderer,
} from '@dayflow-pro/appointment-schedule/plugin';
import OrganiserDrawer from './OrganiserDrawer.vue';

export const drawerRenderer: AppointmentScheduleDrawerRenderer =
  (initial, host) => {
    const state = reactive({ args: initial });
    const app = createApp({
      render: () => h(OrganiserDrawer, { args: state.args }),
    });
    app.mount(host);

    return {
      update: (args: AppointmentScheduleDrawerRenderArgs) => {
        state.args = args;
      },
      destroy: () => app.unmount(),
    };
  };
organiser-drawer.ts
import {
  ApplicationRef,
  Component,
  EnvironmentInjector,
  Input,
  createComponent,
} from '@angular/core';
import type {
  AppointmentScheduleDrawerRenderArgs,
  AppointmentScheduleDrawerRenderer,
} from '@dayflow-pro/appointment-schedule/plugin';

@Component({
  selector: 'app-organiser-drawer',
  standalone: true,
  template: `
    <input [value]="args.draft.title" (input)="updateTitle($event)" />
    <button type="button" (click)="args.save()">Save</button>
    <button type="button" (click)="args.cancel()">Cancel</button>
  `,
})
export class OrganiserDrawerComponent {
  @Input({ required: true }) args!: AppointmentScheduleDrawerRenderArgs;

  updateTitle(event: Event) {
    this.args.draftManager.updateDraft({
      title: (event.target as HTMLInputElement).value,
    });
  }
}

export const createDrawerRenderer = (
  appRef: ApplicationRef,
  environmentInjector: EnvironmentInjector
): AppointmentScheduleDrawerRenderer => (initial, host) => {
  const component = createComponent(OrganiserDrawerComponent, {
    hostElement: host,
    environmentInjector,
  });
  appRef.attachView(component.hostView);
  const update = (args: AppointmentScheduleDrawerRenderArgs) => {
    component.setInput('args', args);
    component.changeDetectorRef.detectChanges();
  };
  update(initial);

  return {
    update,
    destroy: () => {
      appRef.detachView(component.hostView);
      component.destroy();
    },
  };
};
OrganiserDrawer.svelte
<script lang="ts">
  import type { Readable } from 'svelte/store';
  import type { AppointmentScheduleDrawerRenderArgs } from '@dayflow-pro/appointment-schedule/plugin';

  let { state }: { state: Readable<AppointmentScheduleDrawerRenderArgs> } = $props();
  const updateTitle = (event: Event) => {
    $state.draftManager.updateDraft({
      title: (event.target as HTMLInputElement).value,
    });
  };
</script>

<form onsubmit={(event) => { event.preventDefault(); void $state.save(); }}>
  <input value={$state.draft.title} oninput={updateTitle} />
  <button type="submit">Save</button>
  <button type="button" onclick={$state.cancel}>Cancel</button>
</form>
organiserDrawerRenderer.ts
import { mount, unmount } from 'svelte';
import { writable } from 'svelte/store';
import type { AppointmentScheduleDrawerRenderer } from '@dayflow-pro/appointment-schedule/plugin';
import OrganiserDrawer from './OrganiserDrawer.svelte';

export const drawerRenderer: AppointmentScheduleDrawerRenderer =
  (initial, host) => {
    const state = writable(initial);
    const component = mount(OrganiserDrawer, {
      target: host,
      props: { state },
    });

    return {
      update: next => state.set(next),
      destroy: () => void unmount(component),
    };
  };

완성한 렌더러를 createAppointmentSchedulePlugin({ drawerRenderer })에 전달하세요. 초안이 바뀌면 update가, 드로어를 닫거나 렌더러를 교체하면 destroy가 호출됩니다. 렌더 인자에는 캘린더·화상회의 메타데이터, 로케일, timeFormat, translate, isCreating, save(), cancel()도 함께 전달됩니다.

드로어 호스트, 애니메이션, 레이어

렌더러에 넘겨주는 호스트 요소는 플러그인이 소유합니다. 이 요소에는 df-appointment-custom-drawer-host 클래스와 --calendar 또는 --viewport 수식자가 붙고, 위치·너비·쌓임 순서는 플러그인이 인라인으로 지정합니다. 스타일은 이 클래스들을 기준으로 지정하세요. DOM에서 옮기거나 position을 바꾸지 마세요. 렌더링할 때마다 플러그인이 두 값을 다시 적용합니다.

교체한 드로어도 기본 드로어와 똑같이 슬라이드로 나타나고 사라집니다. 닫을 때 플러그인은 호스트에 data-df-drawer-exiting을 표시하고, 키프레임 애니메이션이 끝날 때까지 기다린 다음에야 destroy를 호출하고 호스트를 제거합니다. 덕분에 드로어는 내용이 남아 있는 상태로 사라지는 애니메이션을 완주합니다:

/* Defaults shipped by the package; override to change the motion. */
.df-appointment-custom-drawer-host {
  animation: df-slide-in-left 200ms cubic-bezier(0.16, 1, 0.3, 1);
}

.df-appointment-custom-drawer-host[data-df-drawer-exiting] {
  animation: df-slide-out-left 180ms cubic-bezier(0.7, 0, 0.84, 0) forwards;
}

알아 두면 좋은 세 가지가 있습니다. 기다리는 대상은 키프레임 애니메이션뿐이므로, 프레임워크가 호버나 포커스 링에 붙인 CSS 트랜지션 때문에 드로어가 지연되는 일은 없습니다. 무한 애니메이션은 건너뛰며, 애니메이션 자체의 길이로 제한된 타이머가 대기를 보완하므로 드로어 안의 스피너나 백그라운드 탭(애니메이션 프레임이 멈추는 상황)이 호스트를 문서에 남겨 두는 일은 생기지 않습니다. 그리고 애니메이션을 제거하거나 prefers-reduced-motion: reduce에 해당하면 닫힌 바로 그 프레임에 호스트가 사라지는데, 이는 움직임을 줄이고 싶어 하는 사용자를 위해 패키지가 이미 하고 있는 동작입니다.

드로어는 z-index: 900에 놓이며, 1000에 있는 캘린더의 빠른 생성 팝업과 대화상자보다 아래입니다. 그래서 추가 메뉴를 열었을 때 편집기에 가려지는 일이 없습니다. 드로어를 1000 위로 올리면 이 관계가 뒤집힙니다. 드로어가 그 위를 덮어야 한다면 드로어만 올리지 말고 그쪽도 함께 올리세요.

반복

가용 시간은 기본적으로 매주 반복됩니다. 드로어에서는 일회성 일정과 N주마다 반복하는 사용자 지정 규칙도 선택할 수 있습니다:

type AppointmentRecurrence = {
  frequency: 'weekly' | 'none' | 'custom';
  startDate?: string; // YYYY-MM-DD anchor
  intervalWeeks?: number; // custom: every N weeks
  endsOnDate?: string;
  endsAfterOccurrences?: number;
};

frequency: 'none'은 선택한 주간 시간대를 기준 날짜가 속한 월~일 한 주에만 적용합니다. 가용 시간을 기준 날짜 하루로 좁히지는 않습니다.

월 단위, n번째 요일, RRULE 같은 더 복잡한 규칙은 애플리케이션의 몫입니다. 반복 규칙보다 항상 우선하는 dateOverrides로 표현하세요.

장소와 화상회의

일정에는 예약이 어디서 이루어지는지 지정할 수 있습니다. 이는 설정일 뿐 이미 잡힌 회의가 아닙니다. 하나의 일정은 여러 번 예약될 수 있으므로, 예약별 참가 URL은 저장하지 않습니다:

type AppointmentLocationConfig =
  | { type: 'custom-link'; url: string; label?: string }
  | { type: 'conference'; providerId: string }
  | { type: 'in-person'; address: string }
  | { type: 'phone'; phone?: string };

드로어의 장소 선택기에는 언제나 "사용자 지정 회의 링크", "대면", "전화 통화"가 있습니다. 이름이 지정된 화상회의 앱은 등록한 뒤에야 나타납니다:

createAppointmentSchedulePlugin({
  schedules,
  conferenceProviders: [googleMeet, zoom], // ← the picker lists these first
});

화상회의 제공자

DayFlow는 Google, Zoom, Microsoft와 직접 통신하지 않습니다. 메서드 하나짜리 인터페이스를 정의하고 그것을 호출할 뿐이며, OAuth 토큰·API 시크릿·벤더 SDK는 여러분의 백엔드에 남습니다:

import type { ConferenceProvider } from '@dayflow-pro/appointment-schedule/engine';

const googleMeet: ConferenceProvider = {
  id: 'google-meet',
  name: 'Google Meet',
  icon: '/icons/meet.svg',
  createConference: input =>
    fetch('/api/dayflow/google-meet', {
      method: 'POST',
      body: JSON.stringify({ ...input, start: input.start.toString() }),
    }).then(response => response.json()),
};
// → { provider, joinUrl, meetingId?, hostUrl?, password?, meta? }

createConferenceCreateConferenceInput을 받습니다: scheduleId, title, Temporal startend, timeZone, 그리고 선택적으로 hostattendees입니다. 반환값은 providerjoinUrl을 가진 Conference이며, meetingId, hostUrl, password, meta는 선택 사항입니다.

예약 컴포넌트에도 같은 제공자를 지정하면 위치 행에 이름이 붙고 제공자 아이콘이 표시됩니다:

<AppointmentBooking schedule={schedule} conferenceProviders={[googleMeet]} />

회의 생성 시점

화상회의는 참석자가 확정할 때 만드세요. 슬롯을 눌러 강조만 했을 때 만들면 안 됩니다. 10:00을 클릭했다가 그냥 떠난 사람이 회의를 남겨 두게 해서는 안 됩니다.

import {
  createBookingEvent,
  createConferenceForBooking,
} from '@dayflow-pro/appointment-schedule/engine';

const conference = await createConferenceForBooking({
  slot,
  schedule,
  providers: [googleMeet],
  attendees: [{ name: 'Ada Lovelace', email: 'ada@example.com' }],
});

const draft = createBookingEvent({ slot, schedule, conference });
// draft.location  → 'https://meet.google.com/abc-defg-hij'
// draft.conference → { provider: 'google-meet', joinUrl, meetingId }

custom-link 일정은 네트워크 호출 없이 주최자의 고정 회의실로 확정되고, in-personphone 일정은 undefined로 확정됩니다. 등록되지 않은 providerId는 오류를 발생시키는데, 예약이 회의 링크를 조용히 잃어버려서는 안 되기 때문입니다.

하나의 고정 링크를 계속 쓰면 안 되나요?

Google Meet, Zoom, Teams에는 custom-link보다 conference를 사용하세요. Google은 회의 데이터를 여러 이벤트가 공유하면 접근 권한과 개인정보 문제가 생길 수 있으므로, 링크를 재사용하는 대신 이벤트마다 새 회의를 만들 것을 권장합니다.

이메일이나 확인 페이지처럼 다른 곳에 장소를 표시하려면, 회의 정보 행과 동일한 순수 리졸버를 사용하세요:

import { resolveLocation } from '@dayflow-pro/appointment-schedule/engine';

resolveLocation(schedule.location, { providers: [googleMeet] });
// → { kind: 'video', label: 'Google Meet', icon: '/icons/meet.svg', config }

장소가 설정되어 있으면 리졸버가 ResolvedLocation을 반환합니다.

참석자용 예약 컴포넌트

import { AppointmentBooking } from '@dayflow-pro/appointment-schedule/booking';

<AppointmentBooking
  schedule={schedule}
  presentation={{ organiserName: 'Alex Morgan', locationLabel: 'Zoom meeting' }}
  busyIntervals={busyIntervals}
  layout='calendar-day-slots'
  onSelectSlot={slot => console.log(slot.start.toString())}
/>;

presentation에는 회의 정보 패널에 표시할 선택적이고 표현 전용인 내용이 들어갑니다. 가용 시간과 예약 동작은 계속 schedule에서 옵니다.

속성타입용도
organiserNamestring주최자 이름을 표시하고, 아바타가 없을 때 대체용 첫 글자를 제공합니다.
organiserAvatarstring주최자 아바타 이미지의 URL입니다.
organiserUrlstring주최자 아바타에서 열리는 프로필 URL입니다.
titlestring소요 시간·장소·시간대 위에 표시되는 제목입니다. schedule.title을 전달하면 그 제목을 재사용합니다.
descriptionstring회의 메타데이터 아래에 표시되는 보조 설명입니다.
locationLabelstringschedule.location에서 파생된 장소 문구를 덮어씁니다. 보통은 일정의 필드를 쓰는 편이 좋습니다.

예약 옵션

모든 프레임워크 어댑터는 최종적으로 AppointmentBookingProps를 받습니다. Vue, Angular, Svelte는 각자의 진입점에서 이에 대응하는 마운트 타입을 별도 이름으로 제공합니다.

속성 그룹용도
schedule슬롯을 생성하는 데 사용하는 필수 AppointmentSchedule입니다.
calendarAppDayFlow 이벤트를 주최자의 바쁜 시간으로 읽고 구독합니다.
presentation위에서 설명한 선택적 AppointmentPresentation 콘텐츠입니다.
conferenceProviders화상회의 장소의 제공자 이름과 아이콘을 확인합니다.
busyIntervals / attendeeBusyIntervals주최자 슬롯을 제거하거나 참석자의 일정 충돌을 겹쳐 보여 주는 Temporal 구간입니다.
layoutBookingLayout 값입니다: calendar-day-slots, multi-day-slots, week-overlay.
renderWeekOverlay / weekOverlayOptionsWeekOverlayRenderArgsWeekOverlayOptions로 주간 뷰를 켜고 세부 조정합니다.
availableLayouts / onLayoutChange내장 전환기가 제공할 레이아웃을 제어합니다.
displayTimeZone / timeZoneOptions / onDisplayTimeZoneChange참석자에게 표시할 시간대와 시간대 선택기를 제어합니다.
timeFormat / onTimeFormatChangeTimeFormat 값을 12h 또는 24h로 제어합니다.
theme / locale / startOfWeek주간 테마, 로케일, StartOfWeek 값(0, 1, 6)을 설정합니다.
multiDayCount / skipEmptyDays여러 날 레이아웃을 세부 조정합니다.
now / rangeStart / rangeEnd현재 시각과 슬롯 생성 범위를 대체합니다.
selectedDate / onSelectDate제어 방식으로 포커스된 날짜입니다.
selectedSlotId / onSelectSlot제어 방식의 슬롯 선택과 선택 콜백입니다.
loading / disabled로딩 UI를 표시하거나 상호작용을 비활성화합니다.
onError / onRetry자체 오류 보고와 재시도 동작을 연결합니다.
className / style루트 요소에 스타일을 추가합니다.
labels사용자에게 보이는 문구를 원하는 만큼 덮어씁니다.
slots아래에 나열된 영역을 교체하거나 확장합니다.
onAnalyticsEventAppointmentBookingAnalyticsEvent와 개인정보가 아닌 페이로드를 전달받습니다.

보여 줘야 할 가용 시간의 양에 맞춰 레이아웃을 선택하세요:

레이아웃적합한 경우
calendar-day-slots월 선택기와 선택한 날짜의 시간 목록을 함께 보여 줄 때.
multi-day-slots여러 날의 가능한 시간을 열로 나란히 비교할 때.
week-overlay주간 타임라인 위에 예약 가능한 시간을 표시할 때. 아래 렌더러가 필요합니다.

주간 레이아웃은 @dayflow/core가 필요한 유일한 레이아웃이라 선택적으로 제공됩니다:

import { renderWeekOverlay } from '@dayflow-pro/appointment-schedule/booking/week-overlay';

<AppointmentBooking
  layout='week-overlay'
  renderWeekOverlay={renderWeekOverlay}
/>;

프레임워크 어댑터

예약 페이지를 담당하는 프레임워크의 어댑터를 사용하세요. 모든 어댑터가 동일한 예약 옵션을 받고 마운트, 반응형 갱신, 정리를 처리합니다. 어댑터 하나를 가져와도 다른 프레임워크의 런타임이 번들에 포함되지는 않습니다.

어떤 어댑터에서도 공유할 수 있는 직렬화 가능한 일정을 만드세요:

schedule.ts
import type { AppointmentSchedule } from '@dayflow-pro/appointment-schedule/engine';

export const schedule: AppointmentSchedule = {
  id: 'product-demo',
  title: 'Product demo',
  durationMinutes: 30,
  slotIntervalMinutes: 30,
  timeZone: 'Europe/London',
  recurrence: { frequency: 'weekly' },
  availability: [
    {
      dayOfWeek: 1,
      intervals: [{ id: 'monday', startTime: '09:00', endTime: '17:00' }],
    },
    {
      dayOfWeek: 2,
      intervals: [{ id: 'tuesday', startTime: '09:00', endTime: '17:00' }],
    },
    {
      dayOfWeek: 3,
      intervals: [{ id: 'wednesday', startTime: '09:00', endTime: '17:00' }],
    },
    {
      dayOfWeek: 4,
      intervals: [{ id: 'thursday', startTime: '09:00', endTime: '17:00' }],
    },
    {
      dayOfWeek: 5,
      intervals: [{ id: 'friday', startTime: '09:00', endTime: '17:00' }],
    },
  ],
  location: {
    type: 'custom-link',
    url: 'https://zoom.us/j/1234567890',
    label: 'Zoom meeting',
  },
};

애플리케이션 진입점에서 styles.components.css를 한 번만 가져온 뒤, 해당하는 어댑터를 사용하세요:

import { AppointmentBooking } from '@dayflow-pro/appointment-schedule/react';
import '@dayflow-pro/appointment-schedule/styles.components.css';
import { schedule } from './schedule';

export function BookingPage() {
  return (
    <AppointmentBooking
      schedule={schedule}
      presentation={{
        organiserName: 'Alex Morgan',
        locationLabel: 'Zoom meeting',
      }}
      layout='calendar-day-slots'
      onSelectSlot={slot => console.log(slot.start.toString())}
    />
  );
}
<script setup lang="ts">
import { AppointmentBooking } from '@dayflow-pro/appointment-schedule/vue';
import type { MountAppointmentBookingProps } from '@dayflow-pro/appointment-schedule/vue';
import '@dayflow-pro/appointment-schedule/styles.components.css';
import { schedule } from './schedule';

const bookingOptions: MountAppointmentBookingProps = {
  schedule,
  presentation: {
    organiserName: 'Alex Morgan',
    locationLabel: 'Zoom meeting',
  },
  layout: 'calendar-day-slots',
  onSelectSlot: slot => console.log(slot.start.toString()),
};
</script>

<template>
  <AppointmentBooking :options="bookingOptions" />
</template>
import { Component } from '@angular/core';
import {
  AppointmentBookingDirective,
  type MountAppointmentBookingProps,
} from '@dayflow-pro/appointment-schedule/angular';
import '@dayflow-pro/appointment-schedule/styles.components.css';
import { schedule } from './schedule';

@Component({
  standalone: true,
  imports: [AppointmentBookingDirective],
  template: '<div [dfAppointmentBooking]="bookingOptions"></div>',
})
export class BookingPage {
  readonly bookingOptions: MountAppointmentBookingProps = {
    schedule,
    presentation: {
      organiserName: 'Alex Morgan',
      locationLabel: 'Zoom meeting',
    },
    layout: 'calendar-day-slots',
    onSelectSlot: slot => console.log(slot.start.toString()),
  };
}
<script lang="ts">
  import { appointmentBooking } from '@dayflow-pro/appointment-schedule/svelte';
  import type { SvelteAppointmentBookingOptions } from '@dayflow-pro/appointment-schedule/svelte';
  import '@dayflow-pro/appointment-schedule/styles.components.css';
  import { schedule } from './schedule';

  const options: SvelteAppointmentBookingOptions = {
    schedule,
    presentation: {
      organiserName: 'Alex Morgan',
      locationLabel: 'Zoom meeting',
    },
    layout: 'calendar-day-slots',
    onSelectSlot: slot => console.log(slot.start.toString()),
  };
</script>

<div use:appointmentBooking={options}></div>

options 객체를 받는 어댑터는 사용자 정의 영역을 options.slots로 노출하고, props를 쓰는 컴포넌트는 동일한 slots 계약을 그대로 노출합니다. 예약을 갱신하려면 옵션 객체를 교체하세요. 슬롯 렌더러 모음을 바꾸면 안전하게 다시 마운트되며, 호스트를 언마운트하면 예약 인스턴스가 정리됩니다.

연동 API

단순 마운트 API

다른 프레임워크에서든 프레임워크 없는 페이지에서든, 예약 UI를 직접 마운트하고 싶다면 createAppointmentBooking을 사용하세요:

import { createAppointmentBooking } from '@dayflow-pro/appointment-schedule/booking';

const booking = createAppointmentBooking('#booking', {
  schedule,
  onSelectSlot: slot => console.log(slot.start.toString()),
});

booking.update({ disabled: true });
booking.destroy();

대상 요소의 기존 자식은 건드리지 않습니다. 마운트 API는 자식 요소 하나를 만들어 소유하고, destroy() 시 그것을 제거합니다.

DOM 슬롯은 현재 인자와, 렌더러가 소유하는 컨테이너를 전달받습니다:

createAppointmentBooking('#booking', {
  schedule,
  slots: {
    slotButton: (args, host) => {
      host.textContent = `${args.formattedTime} · ${price(args.slot)}`;
    },
  },
});

렌더러는 아무것도 반환하지 않거나, 정리 함수를 반환하거나, { update, destroy }를 반환할 수 있습니다. 핸들 형태는 패치가 아니라 append 방식으로 동작하는 프레임워크 마운트 API를 위한 것입니다.

헤드리스 예약 컨트롤러

패키지가 제공하는 예약 동작은 그대로 쓰되 마크업은 직접 작성하고 싶다면 createBookingController를 사용하세요. 선택된 날짜와 슬롯, 시간대와 시간 형식, 참석자 바쁜 시간 표시 여부, 슬롯 그룹화를 관리하지만 DOM은 전혀 건드리지 않습니다:

import { createBookingController } from '@dayflow-pro/appointment-schedule/controller';

const booking = createBookingController({ schedule });
const unsubscribe = booking.subscribe(() => render(booking.getState()));

booking.getState().selectSlot(slot);
booking.setOptions({ schedule, disabled: true });

unsubscribe();
booking.destroy();

커스터마이징

디자인 토큰

.df-appointment-booking은 참석자 컴포넌트의 테마 범위이지, 유일한 토큰은 아닙니다. 이 패키지는 아래 커스텀 속성을 지원합니다. 재정의 값은 패키지 스타일시트보다 뒤에 불러오고 .df-appointment-booking에 지정하거나, 사용자 지정 className을 넘겨 두 클래스를 함께 지정하세요.

.df-appointment-booking.my-booking-theme {
  --df-ap-accent: #2563eb;
  --df-ap-radius: 6px;
  --df-ap-sidebar-width: 320px;
}
토큰기본값제어 대상
--df-ap-surface코어 배경주요 표면
--df-ap-surface-sunken코어 muted안쪽으로 들어간 표면
--df-ap-fill#e8eaef중립적인 채움 색
--df-ap-border코어 테두리테두리와 구분선
--df-ap-fg코어 전경색기본 텍스트
--df-ap-fg-muted코어 muted 전경색보조 텍스트
--df-ap-fg-subtle#9ca3af옅은 텍스트
--df-ap-accent코어 primary선택된 컨트롤
--df-ap-accent-fg코어 primary 전경색강조 표면 위의 텍스트
--df-ap-accent-soft강조색에서 파생부드러운 호버 배경
--df-ap-accent-ring강조색에서 파생포커스·선택 링
--df-ap-available#22c55e가용 여부 표시
--df-ap-radius14px카드 모서리 반경
--df-ap-radius-md9px컨트롤 모서리 반경
--df-ap-radius-sm7px작은 항목의 모서리 반경
--df-ap-max-width1440px예약 화면의 최대 너비
--df-ap-sidebar-width296px사이드바 너비
--df-ap-slots-width320px하루 슬롯 열의 너비
--df-ap-week-height620px주간 타임라인 높이
--df-ap-slot-scroll-heightnone하루 슬롯 목록의 높이
--df-ap-pad1.5rem주요 내부 여백
--df-ap-font시스템 폰트 스택예약 화면의 타이포그래피

호버·선택·포커스 색상은 --df-ap-accent에서 파생됩니다. 고급 테마에서는 --df-ap-accent-soft--df-ap-accent-ring을 직접 재정의할 수도 있습니다.

주최자 드로어는 DayFlow 캘린더의 일부이므로 참석자 토큰이 아니라 코어 테마를 사용합니다:

:root {
  --df-color-background: #ffffff;
  --df-color-card: #ffffff;
  --df-color-foreground: #172033;
  --df-color-muted: #f4f6f8;
  --df-color-muted-foreground: #667085;
  --df-color-border: #d0d5dd;
  --df-color-primary: #7c3aed;
  --df-color-primary-foreground: #ffffff;
  --df-color-destructive: #dc2626;
  --df-color-ring: #7c3aed;
}

drawerPlacementviewport일 때는 드로어가 캘린더 요소 바깥에 마운트되므로 :root를 사용하세요. calendar 배치라면 캘린더 컨테이너에 변수를 지정해도 됩니다.

슬롯

슬롯은 모든 어댑터가 지원하지만, 렌더러의 시그니처는 서로 다릅니다:

  • React 슬롯은 React 콘텐츠를 반환합니다. slotButtondayCelldefaultContent도 함께 받으므로 기본 콘텐츠를 감쌀 수 있습니다.
  • Vue, Angular, Svelte는 options.slots를 사용합니다. 각 렌더러는 (args, host)를 받아 전달된 DOM 요소에 내용을 씁니다. 프레임워크 컴포넌트를 마운트할 때는 정리 함수나 { update, destroy } 핸들을 반환할 수 있습니다.

다음 예시는 각 프레임워크에서 동일한 slotButton 영역을 올바르게 커스터마이징하는 방법을 보여 줍니다:

<AppointmentBooking
  schedule={schedule}
  slots={{
    slotButton: ({ defaultContent }) => (
      <>
        {defaultContent}
        <span>$120</span>
      </>
    ),
  }}
/>
import type { DomBookingSlots } from '@dayflow-pro/appointment-schedule/vue';

const slots: DomBookingSlots = {
  slotButton: ({ formattedTime }, host) => {
    host.textContent = `${formattedTime} · $120`;
  },
};

const bookingOptions = { schedule, slots };
import type { DomBookingSlots } from '@dayflow-pro/appointment-schedule/angular';

const slots: DomBookingSlots = {
  slotButton: ({ formattedTime }, host) => {
    host.textContent = `${formattedTime} · $120`;
  },
};

export class BookingPage {
  readonly bookingOptions = { schedule, slots };
}
<script lang="ts">
  import type { DomBookingSlots } from '@dayflow-pro/appointment-schedule/svelte';

  const slots: DomBookingSlots = {
    slotButton: ({ formattedTime }, host) => {
      host.textContent = `${formattedTime} · $120`;
    },
  };

  const options = { schedule, slots };
</script>

<div use:appointmentBooking={options}></div>

AppointmentBookingSlots가 아래 영역들을 정의합니다. 내보내는 인자 타입에는 SidebarSlotArgs, MeetingInfoSlotArgs, MonthPickerSlotArgs, SlotListSlotArgs, ToolbarSlotArgs, SlotButtonSlotArgs, DayCellSlotArgs, SelectedSummarySlotArgs가 포함됩니다.

슬롯용도주요 인자
sidebar회의 상세와 미니 캘린더가 있는 측면 열 전체를 교체합니다.schedule, presentation, location, layout, defaultContent
meetingInfo회의 정보 패널을 교체합니다.schedule, presentation, location, timeZone, defaultContent
meetingInfoFooter회의 정보 패널 아래에 내용을 추가합니다.schedule, presentation, location, timeZone
monthPicker본문과 사이드바의 월 선택기를 교체합니다.selectedDate, availableDates, onSelectDate, compact, defaultContent
slotListcalendar-day-slots에서 선택한 날짜의 시간 목록을 교체합니다.date, slots, selectedSlotId, onSelectSlot, defaultContent
toolbarmulti-day-slotsweek-overlay의 툴바를 교체합니다.layout, timeZone, timeFormat, rangeLabel, defaultContent
toolbarExtra툴바 컨트롤 오른쪽에 내용을 추가합니다.layout, timeZone, timeFormat, rangeLabel
slotButton예약 가능한 시간 버튼 내부의 내용을 교체합니다.slot, formattedTime, isSelected, disabled, defaultContent
dayCell월 선택기의 각 날짜 셀 내부 내용을 교체합니다.date, 월, 가용 및 선택 상태, defaultContent
emptyDay선택한 날짜에 가능한 시간이 없을 때 렌더링됩니다.date
emptyRange현재 날짜 범위에 예약 가능한 시간이 없을 때 렌더링됩니다.없음
loading로딩 상태를 교체합니다.없음
error오류 상태를 교체하며, 오류 객체와 선택적 재시도 동작을 전달받습니다.error, retry
selectedSummary슬롯을 선택한 뒤 콘텐츠 아래에 요약을 추가합니다.slot, formattedDate, formattedRange, timeZone

헤드리스: 나만의 UI 만들기

제공되는 레이아웃이 맞지 않는다면 컴포넌트를 아예 쓰지 않아도 됩니다. 엔진은 일정을 받아 슬롯을 반환하는 순수 함수이며, 패키지의 다른 부분은 번들에 포함되지 않습니다.

import { generateSlots } from '@dayflow-pro/appointment-schedule/engine';
import { Temporal } from 'temporal-polyfill';

const slots = generateSlots({
  schedule,
  rangeStart: Temporal.PlainDate.from('2026-08-03'),
  rangeEnd: Temporal.PlainDate.from('2026-08-09'),
  busyIntervals,
  displayTimeZone: 'Europe/London',
  now: Temporal.Now.zonedDateTimeISO('Australia/Sydney'),
});
// slots: { id, scheduleId, start, end, displayTimeZone }[]

입력은 SlotQuery이며, rangeStartrangeEnd는 양 끝을 포함하는 Temporal.PlainDate 값입니다. busyIntervalsattendeeBusyIntervals에는 Temporal start·end를 가진 BusyInterval 값이 들어갑니다. 결과는 AppointmentSlot[]입니다.

엔진은 window, document, 시스템 시간대를 전혀 건드리지 않으므로 서버에서 가져와도 안전합니다. 또한 구성 요소인 expandAvailability, recurrenceAppliesOn, sortBusyIntervals, mergeBusyIntervals, hasConflict, eventsToBusyIntervals도 내보내므로, 이를 조합해 직접 파이프라인을 구성할 수 있습니다.

예약을 이벤트로 바꾸기

이 모듈은 이벤트를 직접 쓰지 않습니다. 대신 순수한 매핑 함수를 제공합니다:

import { createBookingEvent } from '@dayflow-pro/appointment-schedule/engine';

const draft = createBookingEvent({
  slot,
  schedule, // schedule.calendarId decides which calendar (and colour)
  attendee: { name: 'Ada Lovelace' }, // you collect it, the module never stores it
  conference, // optional; see Location and conferencing
  // titleTemplate: ({ attendee }) => `1:1 · ${attendee?.name}`,
});
// → { id, title: 'Meeting with Ada Lovelace', start, end, calendarId, location?, conference?, meta }

calendar.addEvent(draft);

meta에는 appointmentScheduleIdappointmentSlotId가 담겨 이벤트를 원래 슬롯까지 추적할 수 있고, 일정에 장소가 있으면 appointmentLocationappointmentConference도 함께 담깁니다.

접근성

WCAG 2.2 AA를 목표로 합니다. 월 선택기는 실제 role="grid"이며 방향키, Home, End, PageUp, PageDown으로 이동할 수 있습니다. 슬롯 버튼은 aria-pressed와 함께 날짜·시간·시간대를 포함한 접근 가능한 이름을 제공하며, 가능·불가능·선택 상태를 색상만으로 나타내지 않습니다.

이 페이지의 내용