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)