WeiterbildungMorrow (Fiktives Unternehmen)
Planung von Schulungsräumen
Die Anmeldungen sprengen den Raum. Beide Termine wählen, sehen, was für beide frei ist, zusammen verschieben und die Ansicht behalten.
- Zeitskala
- Demo mit synthetischen Daten
- Stunden / Wochen
- Pro-Funktionen
- FilterZeilenspaltenMehrfachauswahlGespeicherte Ansichten
SuperScheduler Pro wird erst geladen, wenn Sie es anfordern.
Die Ausgangslage
Morrow ist ein fiktives betriebliches Schulungszentrum mit vier Seminarräumen, zwei Werkstätten und vier Trainern. Kurse laufen über zwei Wochen in Halbtagsblöcken; jeder Termin braucht einen Raum mit passender Ausstattung und genug Plätzen sowie einen Trainer, der weder anderswo unterrichtet noch abwesend ist.
Wenn späte Anmeldungen die Kapazität eines Raums übersteigen, sucht die Koordination einen Raum, der zu allen Terminen frei ist, zum Kurs passt und die Termine zusammenhält. Das per Augenmaß über zehn Tage zu prüfen, ist langsam und fehleranfällig: Die Planung muss es aus einer Auswahl beantworten und die gewohnte Ansicht jeder Koordinatorin einen Klick entfernt halten.
Was Sie tun werdenSie wählen beide Termine eines Kurses aus, sehen, welche Räume und Trainer gemeinsam verfügbar sind, verschieben beide zusammen in den passenden Raum und speichern die Ansicht, um sie später wieder zu öffnen.
Wo die Bibliothek endet und Ihre Anwendung beginnt
SuperScheduler liefert
- Gruppierte Zeilen mit drei Spalten im Zeilenkopf: Name, Plätze und gebuchte Stunden.
- Mehrfachauswahl per Klick und Strg/⌘+Klick, die das Control verwaltet (multiselect.events()), und Ziehen mit Ablehnung von Überschneidungen.
- Zeilenfilter über control.rows.filter() und onRowFilter, Zellklassen über onBeforeCellRender.
- Stunden- und Halbtagsraster ohne Nächte und Wochenenden, Tastaturnavigation und Ansagen.
- getViewState und applyViewState aus super-scheduler/views: Zoom, Scrollposition und eingeklappte Gruppen als JSON.
Der Code dieses Beispiels entscheidet
- Beispielregeln zwischen Ressourcen: Ausstattung, Plätze und Verfügbarkeit des Trainers für jeden Termin.
- Gemeinsame Verfügbarkeit: Räume und Trainer, die für alle ausgewählten Termine frei und geeignet sind.
- Das Mitziehen der übrigen ausgewählten Termine: Die Anwendung verschiebt sie nach dem Ablegen mit control.events.update().
- Der Platzfilter, der Filter „Frei für Auswahl“ und die aus den Terminen abgeleitete Unterrichtszeit der Trainer.
- Wo Ansichten gespeichert werden (hier für die Dauer des Besuchs im Speicher), ihre Namen und alle Texte dieser Seite.
Der Code hinter dieser Demo
Das sind die Dateien, die diese Seite ausführt, keine vereinfachte Kopie. Sie kompilieren gegen die öffentlichen Exporte von SuperScheduler Pro.
Die Integration: Spalten im Zeilenkopf, Mehrfachauswahl, die Prüfungen beim Verschieben und das Mitziehen der übrigen Auswahl, die Verfügbarkeitsmarkierung in onBeforeCellRender und der Filter für rows.filter().
/**
* Morrow room board: classrooms, workshops and instructors over two teaching weeks.
*
* Library: grouped rows with row-header columns, hour and half-day grids with nights and weekends
* hidden, multi-selection (Ctrl/⌘+click), row filtering through onRowFilter, cell styling through
* onBeforeCellRender, keyboard and announcements.
* Application (example logic): which room suits which course, when an instructor is free, and
* which rooms and instructors share availability with the selected sessions, and moving the other
* selected sessions along with a dragged one. See ./rules.
*/
import { useEffect, useMemo, useSyncExternalStore, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { HOUR, parseCivil, type CivilDateTime } from '../../scheduler/civil'
import { toneClass } from '../../scheduler/tones'
import { createSpanHeaderHook } from '../kit/span-header-hook'
import {
COURSE_TONES,
civilOf,
hoursLabel,
isInstructorId,
isRoomId,
readCentreFields,
toResources,
type CentreEvent,
} from './adapter'
import {
COURSE_KIT,
INITIAL_VIEW_START,
PERIOD,
TEACHING_HOURS,
type CentreItem,
type RoomId,
type Session,
} from './model'
import {
companionLandings,
instructorIssue,
roomFree,
roomIssue,
roomOf,
sessionIssues,
sharedAvailability,
withinTeachingHours,
} from './rules'
import type { MorrowText, SeatFilter, ZoomId } from './text'
import type { MorrowFilters } from './views'
import './morrow.css'
/** The toolbar's filters, handed to `control.rows.filter()` and read back in onRowFilter. */
export class RowFilter {
constructor(
readonly minSeats: number,
/** Rows to keep (shared availability plus the rows of the selection), or null for all. */
readonly only: ReadonlySet<string> | null,
) {}
}
export interface MorrowSchedulerProps {
readonly events: CentreEvent[]
/** The same data read back as the centre's model, for the rules. */
readonly items: readonly CentreItem[]
/** Ids of the selected sessions (mirrors the control's multiselect). */
readonly selected: readonly string[]
readonly filters: MorrowFilters
readonly text: MorrowText
/** BCP 47 tag for labels, and the scheduler locale id. */
readonly tag: string
readonly schedulerLocale: string
/** Level on mount. Later changes go through control.zoom or saved views. */
readonly initialZoom: ZoomId
readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
readonly onSelectionChange: (ids: readonly string[]) => void
readonly onMoved: (ids: readonly string[]) => void
/** A refusal while dragging (null once the target is valid again) or after a keyboard move. */
readonly onRefused: (message: string | null) => void
readonly onZoomLevel: (level: ZoomId) => void
}
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
{
id: 'hours',
properties: {
scale: 'Hour',
cellWidth: 48,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
},
},
// Morning and afternoon blocks: 8:00-12:00 and 12:00-16:00.
{
id: 'week',
properties: {
scale: 'CellDuration',
cellDuration: 240,
cellWidth: 120,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['hours', 'week']
const MIN_SEATS = { all: 0, '16': 16, '24': 24 } as const satisfies Record<SeatFilter, number>
export function MorrowScheduler(props: MorrowSchedulerProps) {
const { events, items, selected, filters, text, tag, schedulerLocale, initialZoom, controlRef } =
props
const { onEventsChange, onSelectionChange, onMoved, onRefused, onZoomLevel } = props
const resources = useMemo(() => toResources(text), [text])
const issues = useMemo(() => sessionIssues(items), [items])
// Shared availability of the selection, and the time spans the grid highlights for it.
const selection = useMemo(
() =>
items.filter(
(item): item is Session => item.kind === 'session' && selected.includes(item.id),
),
[items, selected],
)
const shared = useMemo(() => sharedAvailability(selection, items), [selection, items])
const fitRows = useMemo(() => new Set<string>([...shared.rooms, ...shared.instructors]), [shared])
const selectedSpans = useMemo(
() => selection.map((session) => [parseCivil(session.start), parseCivil(session.end)] as const),
[selection],
)
// Instructor rows show when each instructor teaches, derived from the sessions on room rows.
const teaching = useMemo(() => {
const spans = new Map<string, (readonly [number, number])[]>()
for (const item of items) {
if (item.kind !== 'session') continue
spans.set(item.instructor, [
...(spans.get(item.instructor) ?? []),
[parseCivil(item.start), parseCivil(item.end)] as const,
])
}
return spans
}, [items])
const compact = useNarrowScreen()
const headerHook = useMemo(
() =>
createSpanHeaderHook(tag, (span, level) =>
level === 'group' ? 'day' : span >= 4 * HOUR ? 'hours-4' : 'hour',
),
[tag],
)
const config = useMemo<SchedulerProps>(() => {
const sessions = new Map(
items.flatMap((item) => (item.kind === 'session' ? [[item.id, item] as const] : [])),
)
// The selected sessions that follow a dragged one (only when the dragged one is selected too).
const companionsOf = (id: string, room: RoomId, start: string) => {
const dragged = sessions.get(id)
return dragged !== undefined && selection.some((session) => session.id === id)
? companionLandings(dragged, room, start, selection)
: []
}
// Why the centre refuses this landing, for the dragged session and each companion, or null.
const refusal = (
data: object,
resource: SuperScheduler.ResourceId,
start: CivilDateTime,
end: CivilDateTime,
): string | null => {
const fields = readCentreFields(data)
if (fields === null) return null
if (fields.kind === 'away') return text.rules.fixed
if (!isRoomId(resource)) return text.rules.notRoom
const id = 'id' in data ? String(data.id) : ''
const companions = companionsOf(id, resource, start)
const moving = new Set([id, ...companions.map((landing) => landing.session.id)])
const landings = [
{ ...fields, room: resource, start, end, companion: false },
...companions.map((landing) => ({ ...landing.session, ...landing, companion: true })),
]
for (const landing of landings) {
if (!withinTeachingHours(landing)) return text.rules.hours
const room = roomIssue(landing.course, landing.attendees, landing.room)
const kit = COURSE_KIT[landing.course]
if (room === 'kit' && kit !== null) return text.rules.kit[kit]
if (room === 'seats') return text.rules.seats
// The library checks overlaps for the dragged session only; companions are checked here.
if (landing.companion && !roomFree(landing.room, landing, items, moving))
return text.rules.roomTaken
const instructor = instructorIssue(landing.instructor, landing, items, moving)
if (instructor !== null) return text.rules[instructor]
}
return null
}
return {
locale: schedulerLocale,
startDate: PERIOD.start,
days: PERIOD.days,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// Courses run 8:00 to 16:00 on weekdays: nights and weekends are hidden.
businessBeginsHour: TEACHING_HOURS.begin,
businessEndsHour: TEACHING_HOURS.end,
showNonBusiness: false,
height: '100%',
treeEnabled: true,
treePreventParentUsage: true,
// On a phone only the name column stays; seats and hours are in the details panel.
rowHeaderColumns: [
{ title: text.columns.resource, width: 124 },
{ title: text.columns.seats, width: 72, hidden: compact },
{ title: text.columns.booked, width: 72, hidden: compact },
],
rowHeaderWidthAutoFit: false,
eventHeight: 26,
rowMarginTop: 4,
rowMarginBottom: 4,
useEventBoxes: 'Never',
allowEventOverlap: false,
// Click selects; Ctrl/⌘+click adds to the selection (the control keeps it in multiselect).
eventClickHandling: 'Select',
allowMultiSelect: true,
dragCard: { duration: false },
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
controlRef,
onEventsChange,
onBeforeTimeHeaderRender: headerHook,
onBeforeRowHeaderRender: (args) => {
const id = args.row.id
if (!isRoomId(id) && !isInstructorId(id)) return
const [name, , booked] = args.row.columns
const label = SuperScheduler.Util.escapeHtml(
isRoomId(id) ? text.rooms[id] : text.instructors[id],
)
const badge = fitRows.has(id)
? `<span class="mr-badge">${SuperScheduler.Util.escapeHtml(text.freeBadge)}</span>`
: ''
if (name !== undefined)
name.html = `<span class="mr-rh"><span class="mr-rh__name">${label}</span>${badge}</span>`
if (booked !== undefined)
booked.html = `<span class="mr-num">${SuperScheduler.Util.escapeHtml(hoursLabel(id, items, text, tag))}</span>`
args.row.cssClass = fitRows.has(id) ? 'mr-rh--fit' : ''
},
onBeforeCellRender: (args) => {
const { cell } = args
if (cell.isParent) return
const from = cell.start.getTime()
const to = cell.end.getTime()
const hit = (spans: readonly (readonly [number, number])[] | undefined) =>
spans?.some(([start, end]) => start < to && from < end) === true
const classes: string[] = []
if (hit(teaching.get(String(cell.resource)))) classes.push('mr-cell--teaching')
if (fitRows.has(String(cell.resource)) && hit(selectedSpans)) classes.push('mr-cell--fit')
cell.properties.cssClass = classes.join(' ')
},
onBeforeEventRender: (args) => {
const fields = readCentreFields(args.data)
if (fields === null) return
if (fields.kind === 'away') {
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(text.away[fields.reason])}</span></span>`
args.data.cssClass = `ss-ev ${toneClass('blocked')} ss-ev--hatched`
return
}
const title = SuperScheduler.Util.escapeHtml(text.courses[fields.course])
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span><span class="ss-evt__meta">${fields.attendees}</span></span>`
const classes = ['ss-ev', 'mr-ev', toneClass(COURSE_TONES[fields.course])]
if (issues.has(String(args.data.id))) classes.push('mr-ev--issue')
// The control tracks the selection; the app draws it, since several sessions can be selected.
if (selected.includes(String(args.data.id))) classes.push('mr-ev--selected')
args.data.cssClass = classes.join(' ')
},
// Room suitability, instructor availability and teaching hours for every session that moves.
onEventMoving: (args) => {
const message = refusal(args.e.data, args.resource, civilOf(args.start), civilOf(args.end))
if (message !== null) {
args.allowed = false
args.message = message
}
onRefused(message)
},
// Final check before the commit (also covers keyboard moves).
onEventMove: (args) => {
const message = refusal(
args.e.data,
args.newResource,
civilOf(args.newStart),
civilOf(args.newEnd),
)
if (message !== null) {
args.preventDefault()
onRefused(message)
}
},
// The library committed the dragged session; the other selected ones follow it through the
// control's API, so one onEventsChange hands all of them back to React state.
onEventMoved: (args) => {
const id = String(args.e.id())
const moved = [id]
if (isRoomId(args.newResource)) {
for (const landing of companionsOf(id, args.newResource, civilOf(args.newStart))) {
const companion = args.control.events.find(landing.session.id)
if (companion === null) continue
args.control.events.update({
...companion.data,
resource: landing.room,
start: landing.start,
end: landing.end,
})
moved.push(landing.session.id)
}
}
onMoved(moved)
},
onEventSelected: () => {
const control = controlRef.current
if (control !== null)
onSelectionChange(control.multiselect.events().map((event) => String(event.id())))
},
onRowFilter: (args) => {
const filter = args.filterParam
if (!(filter instanceof RowFilter)) return
const id = args.row.id
if (isRoomId(id))
args.visible =
roomOf(id).seats >= filter.minSeats && (filter.only === null || filter.only.has(id))
else if (isInstructorId(id)) args.visible = filter.only === null || filter.only.has(id)
},
onZoom: (args) => {
if (args.phase !== 'end') return
const level = ZOOM_IDS[args.level]
if (level !== undefined) onZoomLevel(level)
},
}
}, [
compact,
controlRef,
fitRows,
headerHook,
initialZoom,
issues,
items,
onEventsChange,
onMoved,
onRefused,
onSelectionChange,
onZoomLevel,
schedulerLocale,
selected,
selectedSpans,
selection,
tag,
teaching,
text,
])
// Filters go through the library's row filtering; the rows of the selection always stay.
const rowFilter = useMemo(() => {
const keep =
filters.freeOnly && selection.length > 0
? new Set([
...fitRows,
...selection.flatMap((session) => [session.room, session.instructor]),
])
: null
return new RowFilter(MIN_SEATS[filters.seats], keep)
}, [filters, selection, fitRows])
useEffect(() => {
controlRef.current?.rows.filter(rowFilter)
}, [controlRef, rowFilter])
// Imperative and once: the board opens on Tuesday, the first Data literacy sitting.
useEffect(() => {
controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
}, [controlRef])
return <SuperSchedulerComponent {...config} resources={resources} events={events} />
}
const NARROW = '(max-width: 639px)'
/** True on phone-sized screens (the stage is then too narrow for three header columns). */
function useNarrowScreen(): boolean {
return useSyncExternalStore(
(notify) => {
const query = window.matchMedia(NARROW)
query.addEventListener('change', notify)
return () => query.removeEventListener('change', notify)
},
() => window.matchMedia(NARROW).matches,
() => false,
)
}Dieses Szenario teilen
Der Link öffnet dieses Beispiel mit der aktuellen Ansicht (Zeitskala und Optionen). Er enthält nie Ihre Änderungen oder persönliche Daten.