В этом руководстве мы соберём календарь бронирования переговорных: таймлайн, где у каждой переговорной своя строка, бронирования, которые перетаскиваются на нужное место, правило, которое не даёт забронировать переговорную дважды, и файл для добавления бронирования в любой календарь. Календарь построен на планировщике из @needless-ui/angular, который, как и весь Needless UI, распространяется под лицензией MIT, так что таймлайн ресурсов бесплатен для любого приложения.
Переговорные и бронирования
Переговорные — это ресурсы: id, title и всё остальное в data. Бронирования — это события с обычным временем в формате ISO, например 2026-10-23T09:00, и id своей переговорной в resource. В одном бронировании можно указать и несколько переговорных или людей — списком.
Строка для каждой переговорной
Передайте и переговорные, и бронирования в nui-scheduler с view="timeline". dayStart и dayEnd ограничивают день часами работы, snapMinutes сдвигает бронирования с шагом в четверть часа, а businessHours затеняет время, когда переговорные закрыты. nuiSchedulerResource рисует заголовок каждой строки.
<nui-scheduler
[events]="bookings()"
[resources]="rooms"
view="timeline"
dayStart="8"
dayEnd="19"
snapMinutes="15"
[businessHours]="hours"
editable
selectable
[allow]="free"
(slotSelect)="book($event)"
(eventChange)="move($event)"
(rangeChange)="range.set($event)"
>
<ng-template nuiSchedulerResource let-room>{{ room.title }}</ng-template>
</nui-scheduler>
<button nuiButton type="button" (click)="download()">Add to calendar</button>Бронирование и перенос
Сам планировщик никогда не меняет ваши данные: он лишь сообщает, что делают пользователи. Если протянуть по строке переговорной, выбирается время, и slotSelect сообщает его вместе с переговорной. Если перетащить бронирование или его конец, eventChange сообщает новое время, а если бронирование переехало в другую переговорную — и её. Обновите сигнал, и таймлайн обновится вслед за ним.
import { Component, signal } from '@angular/core';
import { NuiButton } from '@needless-ui/angular/button';
import {
NuiScheduler,
type NuiSchedulerChange,
type NuiSchedulerEvent,
NuiSchedulerResourceTemplate,
type NuiSchedulerSlot,
} from '@needless-ui/angular/scheduler';
import { downloadIcs } from './booking-ics';
@Component({
selector: 'app-bookings',
imports: [NuiButton, NuiScheduler, NuiSchedulerResourceTemplate],
templateUrl: './booking.html',
})
export class Bookings {
/** Rooms are resources; bookings are events, with their room in `resource`. */
protected readonly rooms = [
{ id: 'atlas', title: 'Atlas' },
{ id: 'boreal', title: 'Boreal' },
];
protected readonly bookings = signal<NuiSchedulerEvent[]>([]);
/** Open on weekdays from 8:00 to 19:00: other time is shaded. */
protected readonly hours = [{ days: [1, 2, 3, 4, 5], start: '08:00', end: '19:00' }];
/** The days on screen: load their bookings when it changes. */
protected readonly range = signal<{ start: string; end: string } | null>(null);
protected book({ start, end, resource }: NuiSchedulerSlot): void {
const booking = { id: crypto.randomUUID(), title: 'New booking', start, end, resource };
this.bookings.update((all) => [...all, booking]);
}
protected move({ event, start, end, resource }: NuiSchedulerChange): void {
this.bookings.update((all) =>
all.map((b) =>
b.id === event.id ? { ...b, start, end, resource: resource ?? b.resource } : b,
),
);
}
/** A room holds one booking at a time. */
protected readonly free = (time: NuiSchedulerChange | NuiSchedulerSlot): boolean => {
const id = 'event' in time ? time.event.id : null;
const room = 'event' in time ? (time.resource ?? time.event.resource) : time.resource;
return !this.bookings().some(
(b) => b.id !== id && b.resource === room && b.start < time.end && b.end > time.start,
);
};
protected download(): void {
downloadIcs(this.bookings(), 'bookings');
}
}Без двойных бронирований
Перед тем как создать или перенести бронирование, планировщик спрашивает у allow, можно ли это сделать. Если ответ — false, перетаскивание подсвечивается красным и ничего не меняет, а при работе с клавиатуры планировщик сообщает, что это время недоступно. Пример проверяет другие бронирования той же переговорной; проверьте ещё раз и на сервере — последнее слово за ним.
Загрузка показанных дней
rangeChange сообщает первый и последний показанные дни: один раз в начале и снова при каждом перелистывании. Так можно загружать только бронирования этих дней — через httpResource или любым другим запросом.
Добавление в календарь
nuiSchedulerToIcs() записывает бронирования в файл iCalendar, который импортируют Google Calendar, Outlook и Apple Calendar. nuiSchedulerFromIcs() читает такой файл обратно и переводит время из других часовых поясов в часовой пояс пользователя.
import { type NuiSchedulerEvent, nuiSchedulerToIcs } from '@needless-ui/angular/scheduler';
/** Saves events as an .ics file, which Google Calendar, Outlook and Apple Calendar import. */
export function downloadIcs(events: readonly NuiSchedulerEvent[], name: string): void {
const file = new Blob([nuiSchedulerToIcs(events, { name })], { type: 'text/calendar' });
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = `${name}.ics`;
link.click();
URL.revokeObjectURL(link.href);
}Клавиатура и скринридеры
Всё это работает и без мыши. Таймлайн — одна позиция в порядке табуляции: клавиши-стрелки перемещают по времени и переговорным, Shift со стрелками выбирает время, а Enter его бронирует. Когда фокус на бронировании, Alt со стрелками переносит его на другое время или в другую переговорную. Каждое бронирование — это кнопка, имя которой складывается из названия, переговорной, дня и времени.
О переговорных, стоящих рядом в виде дня, о повторяющихся бронированиях и обо всех остальных параметрах читайте на странице планировщика и в справочнике по API.