event-calendar

Event calendar with drag-to-book appointments

Event calendar with drag-to-book appointments

Загрузка превью…

src/event-calendar/c-event-calendar-5.vue

<!--
  ПОРТ c-event-calendar-5 ("Event calendar with drag-to-book appointments").
  IconPlaceholder ("list"/"plus") заменён инлайновым `<svg>` (пути lucide
  "list"/"plus" v0.545.0). `randomuser.me` сетевые аватары заменены
  статичным `PIXEL` (data-URI, та же константа что в `avatar`/`badge`/
  `block-card` — сетевые изображения запрещены в headless-стенде).
  `renderEvent` передан через `view-config`-проп `EventCalendar` (см.
  c-event-calendar-1.vue). `onSelectSlot`/`canSelectSlot` — обычные callback-
  опции движка (`EventCalendarCallbacks`), реализованы по оригиналу без
  адаптации: drag-to-book работает по-настоящему через `calRef.api`.
  `EventCalendarNav` — настоящий порт, без view switcher (см.
  EventCalendarNav.vue) — оригинал переключатель не отключает, но у нашего
  порта его нет ни в одном кейсе/блоке (см. комментарий в
  EventCalendarNav.vue); начальный вид — "day", поэтому отсутствие
  переключателя не меняет то, что видно на статичном скриншоте.
  `timeZone="UTC"` и фиксированный якорь `Date.UTC(2026, 5, 15)` — те же
  причины, что и в `ui-event-calendar`.
-->
<script setup lang="ts">
import { h, ref } from "vue"
import { addMinutes, setHours, startOfDay } from "date-fns"
import { EventCalendar, EventCalendarContent, EventCalendarNav, type CalendarEvent, type EventCalendarApi, type EventCalendarRenderEventProps, type EventCalendarSlotDraft } from "@/components/reui/event-calendar"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"

const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="

const ANCHOR = new Date(Date.UTC(2026, 5, 15))

const SERVICE = {
  consultation: { label: "Consultation", color: "var(--color-violet-500)" },
  followup: { label: "Follow-up", color: "var(--color-sky-500)" },
  assessment: { label: "Assessment", color: "var(--color-amber-500)" },
  therapy: { label: "Therapy", color: "var(--color-rose-500)" },
} as const

type Service = keyof typeof SERVICE
const NEW_COLOR = "var(--color-emerald-500)"

interface ApptData {
  client: string
  initials: string
  avatar?: string
  service?: Service
}

function buildAppointments(anchor: Date): CalendarEvent<ApptData>[] {
  const base = startOfDay(anchor)
  const at = (hour: number, minute = 0) => addMinutes(setHours(base, hour), minute)

  const appt = (
    id: string,
    client: string,
    initials: string,
    avatar: string,
    service: Service,
    startHour: number,
    startMinute: number,
    minutes: number
  ): CalendarEvent<ApptData> => {
    const start = at(startHour, startMinute)
    return {
      id,
      title: client,
      start,
      end: addMinutes(start, minutes),
      color: SERVICE[service].color,
      data: { client, initials, avatar, service },
    }
  }

  return [
    appt("a1", "Dana Whitfield", "DW", PIXEL, "consultation", 9, 0, 45),
    appt("a2", "Marco Reyes", "MR", PIXEL, "followup", 11, 0, 45),
    appt("a3", "Priya Nair", "PN", PIXEL, "assessment", 14, 0, 60),
    appt("a4", "Leon Fischer", "LF", PIXEL, "therapy", 16, 0, 45),
  ]
}

const events = buildAppointments(ANCHOR)

function renderChip({ occurrence, segment }: EventCalendarRenderEventProps<ApptData>) {
  const data = occurrence.event.data
  if (!data) return undefined
  const service = data.service ? SERVICE[data.service] : null
  const minutes = (segment.endMin ?? 0) - (segment.startMin ?? 0)

  const children = [
    h("span", { class: "flex min-w-0 items-center gap-1.5" }, [
      h(Avatar, { class: "size-5 shrink-0" }, () => [
        data.avatar ? h(AvatarImage, { src: data.avatar, alt: data.client }) : null,
        h(AvatarFallback, { class: "bg-(--ec-event-color)/25 text-[9px] font-semibold text-(--ec-event-color)" }, () => data.initials),
      ]),
      h("span", { class: "truncate font-medium" }, data.client),
    ]),
  ]
  if (service && minutes >= 45) {
    children.push(
      h("span", { class: "truncate ps-[1.625rem] text-[11px] font-medium text-(--ec-event-color)" }, service.label)
    )
  }
  return h("span", { class: "flex h-full w-full min-w-0 flex-col justify-start gap-0.5" }, children)
}

const calRef = ref<{ api: EventCalendarApi<ApptData> } | null>(null)
let counter = 0

function bookSlot(slot: EventCalendarSlotDraft) {
  calRef.value?.api.addEvent({
    id: `appt-${counter++}`,
    title: "New appointment",
    start: slot.start,
    end: slot.end,
    color: NEW_COLOR,
    data: { client: "New appointment", initials: "+" },
  })
}

function canBook(slot: EventCalendarSlotDraft) {
  const api = calRef.value?.api
  if (!api) return true
  return api.findOverlapping({ start: slot.start, end: slot.end }).length === 0
}

function addAppointment() {
  const api = calRef.value?.api
  if (!api) return
  const base = startOfDay(ANCHOR)
  for (let minutes = 9 * 60; minutes + 45 <= 18 * 60; minutes += 30) {
    const start = addMinutes(base, minutes)
    const end = addMinutes(start, 45)
    if (api.findOverlapping({ start, end }).length === 0) {
      api.addEvent({
        id: `appt-${counter++}`,
        title: "New appointment",
        start,
        end,
        color: NEW_COLOR,
        data: { client: "New appointment", initials: "+" },
      })
      api.goTo(start)
      return
    }
  }
}
</script>

<template>
  <div class="w-full p-4">
    <Card class="w-full py-0">
      <CardContent class="p-0">
        <EventCalendar
          ref="calRef"
          :events="events"
          view="day"
          :day-start-hour="9"
          :day-end-hour="18"
          :interval="30"
          :snap-duration="15"
          :on-select-slot="bookSlot"
          :can-select-slot="canBook"
          :interactions="{ drag: true, resize: true, selectSlot: true }"
          :view-config="{ renderEvent: renderChip }"
          time-zone="UTC"
          class="h-[600px] w-full"
        >
          <div class="flex flex-wrap items-center gap-2 pe-2">
            <EventCalendarNav class="min-w-0 flex-1" />
            <div data-slot="event-calendar-toolbar" class="flex items-center gap-2">
              <Button variant="outline" size="sm" @click="calRef?.api.setView('agenda')">
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M3 5h.01" /><path d="M3 12h.01" /><path d="M3 19h.01" /><path d="M8 5h13" /><path d="M8 12h13" /><path d="M8 19h13" /></svg>
                Agenda
              </Button>
              <Button size="sm" @click="addAppointment">
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
                New appointment
              </Button>
            </div>
          </div>
          <EventCalendarContent />
        </EventCalendar>
        <p class="text-muted-foreground border-t px-4 py-3 text-xs">Drag across an open time to book an appointment. Slots that overlap an existing booking are blocked.</p>
      </CardContent>
    </Card>
  </div>
</template>

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-event-calendar-5.json

Зависимости реестра

npm-зависимости

  • date-fns

Источник: порт из ReUI (Keenthemes, MIT)