SportCourt Club (Fiktives Unternehmen)
Platzbuchung im Sportclub
Am Vormittag reißt ein Padel-Netz. Kurs verschieben, Platz sperren und jeden Coach in seinem Dienst lassen.
- Zeitskala
- Demo mit synthetischen Daten
- Minuten / Stunden
- Pro-Funktionen
- ThemingKontextmenüVerschieberegelnGesperrte ZeitenStufenloser Zoom
SuperScheduler Pro wird erst geladen, wenn Sie es anfordern.
Die Ausgangslage
Court Club ist ein fiktiver Racket- und Fitnessclub mit drei Tennisplätzen, drei Padel-Plätzen, einem Cycling-Studio und einem Mattenstudio. Mitglieder buchen im Halbstundentakt, und Coaches geben Kurse auf jedem Belag ihrer Sportart, aber nur während ihres Dienstes.
Am Empfang ändert sich das Wochenende im Minutentakt: Ein Netz reißt, ein Kurs läuft als Pilates statt Yoga, auf einem Platz müssen die Linien gekehrt werden. Die Planung muss einen Padel-Kurs auf Sand, einen Coach an zwei Orten zugleich und eine Buchung über einer anderen ablehnen und die passenden Befehle genau dort anbieten, wo der Empfang gerade hinschaut.
Was Sie tun werdenSie verschieben einen Padel-Kurs auf einen freien Platz, sperren den defekten Platz per Kontextmenü für die Reparatur und stellen einen Studiokurs auf eine andere Aktivität um. Jede Ablehnung wird erklärt.
Wo die Bibliothek endet und Ihre Anwendung beginnt
SuperScheduler liefert
- Halbstunden- und Stundenraster nur mit den Öffnungszeiten, mit animiertem Wechsel zwischen beiden.
- Ziehen und Dauer ändern mit Ablehnung von Überschneidungen pro Zeile und Hervorhebung der störenden Buchung.
- Kontextmenüs mit SuperScheduler.Menu für Buchungen und markierte Zeiträume, mit Untermenüs, deaktivierten Einträgen und Umschalt+F10.
- Die Ziehkarte, die sagt, warum ein Ablegen abgelehnt wird, und Tastaturverschiebungen mit Ansagen.
- Ein Design aus --super-scheduler-*-Tokens auf einer Root-Klasse, beschränkt auf diese Planung.
Der Code dieses Beispiels entscheidet
- Welcher Belag welche Aktivität erlaubt, und der Satz, der bei jeder Ablehnung erscheint.
- Coach-Prüfungen über Zeilen hinweg: im Dienst und nicht gleichzeitig in einem anderen Kurs.
- Was jeder Menübefehl bewirkt und welche Dauer vor die nächste Buchung oder den Schluss passt.
- Die Auslastung in den Zeilenköpfen, das Detailpanel 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: zwei Rasterstufen, nur Öffnungszeiten, die Prüfungen beim Verschieben und beim Ändern der Dauer und beide Menüs, die beim Öffnen aus den aktuellen Buchungen befüllt werden.
/**
* Court Club booking board: courts, studios and the coaches' rota over one weekend.
*
* Library: half-hour and hourly grids, drag/resize, per-row overlap refusal with conflict
* highlight, SuperScheduler.Menu context menus, keyboard (Shift+F10 opens the menus) and a skin
* made of CSS tokens scoped to this board (./skin.css).
* Application: which surface hosts which activity, whether the coach is on duty and free, and what
* each menu command does to the bookings. Those rules live in ./rules.
*/
import { useEffect, useLayoutEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { plural } from '~/i18n/format'
import { MINUTE, formatCivil, minutesBetween, type CivilDateTime } from '../../scheduler/civil'
import { usesTwelveHours } from '../../scheduler/header-format'
import { toneClass } from '../../scheduler/tones'
import { createSpanHeaderHook } from '../kit/span-header-hook'
import {
civilOf,
isVenueId,
itemLabel,
itemTone,
readClubFields,
rowMetrics,
toResources,
type ClubEvent,
} from './adapter'
import {
OPENING_HOURS,
WEEKEND,
type Activity,
type ClubItem,
type RowId,
type VenueId,
} from './model'
import {
ACTIVITIES_BY_VENUE,
LENGTHS,
canHost,
coachIssue,
lengthIssue,
venueConflicts,
venueKind,
type Length,
} from './rules'
import type { CourtClubText, ZoomId } from './text'
import './skin.css'
/** Menu commands. The demo applies them to its React state, the single source of truth. */
export interface ClubActions {
readonly switchActivity: (id: string, activity: Activity) => void
readonly setLength: (id: string, minutes: Length) => void
readonly cancel: (id: string) => void
readonly reopen: (id: string) => void
readonly block: (venue: VenueId, start: CivilDateTime, end: CivilDateTime) => void
readonly hire: (venue: VenueId, start: CivilDateTime, end: CivilDateTime) => void
}
export interface CourtClubSchedulerProps {
readonly events: ClubEvent[]
/** The same data read back as the club's model, for the rules. */
readonly items: readonly ClubItem[]
readonly text: CourtClubText
/** 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 so the view animates from where it is. */
readonly initialZoom: ZoomId
readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
/** Receives the booking menu, so the detail panel's "Options" button can open it too. */
readonly menuRef: RefObject<SuperScheduler.Menu | null>
readonly actions: ClubActions
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
readonly onSelect: (id: string) => void
readonly onMoved: (id: string) => void
readonly onResized: (id: 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: 'half-hours',
properties: {
scale: 'CellDuration',
cellDuration: 30,
cellWidth: 56,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
},
},
{
id: 'hours',
properties: {
scale: 'Hour',
cellWidth: 64,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['half-hours', 'hours']
/** Rows that start a new family of venues get a court line above them. */
const SECTION_STARTS: ReadonlySet<SuperScheduler.ResourceId> = new Set<RowId>([
'padel-1',
'studio-a',
'coach-iris',
])
/** What the menus need when they open; refreshed by the board after every render. */
interface MenuContext {
readonly items: CourtClubSchedulerProps['items']
readonly text: CourtClubSchedulerProps['text']
readonly actions: ClubActions
readonly tag: string
readonly clearSelection: () => void
}
/**
* Both menus are created once, outside render. What they offer is decided when they open, from
* the context the board hands over after each render.
*/
function createClubMenus() {
let context: MenuContext | null = null
const selection = new SuperScheduler.Menu({
theme: 'cc_menu',
showMenuTitle: true,
onShow: (args) => {
const source = args.source
if (
!(source instanceof SuperScheduler.Selection) ||
source.resource === undefined ||
!isVenueId(source.resource)
) {
args.preventDefault()
return
}
if (context === null) {
args.preventDefault()
return
}
const { items: current, text: t, actions: run, tag: lang } = context
const venue = source.resource
const start = civilOf(source.start)
const end = civilOf(source.end)
const free = venueConflicts(venue, { start, end }, current).length === 0
const courts = venueKind(venue) !== 'bikes' && venueKind(venue) !== 'mats'
args.menu.menuTitle = `${t.rows[venue]} · ${timeRange(start, end, lang)}`
args.menu.items = [
{ text: t.menu.block, disabled: !free, onClick: () => run.block(venue, start, end) },
{
text: t.menu.hire,
disabled: !free,
hidden: !courts,
onClick: () => run.hire(venue, start, end),
},
]
},
// A held selection would linger under the next booking: clear it whatever was chosen.
onHide: () => context?.clearSelection(),
})
const booking = new SuperScheduler.Menu({
theme: 'cc_menu',
showMenuTitle: true,
onShow: (args) => {
const source = args.source
const fields = source instanceof SuperScheduler.Event ? readClubFields(source.data) : null
// The rota has no commands: no menu for shifts.
if (!(source instanceof SuperScheduler.Event) || fields === null || fields.kind === 'shift') {
args.preventDefault()
return
}
if (context === null) {
args.preventDefault()
return
}
const { items: current, text: t, actions: run, tag: lang } = context
const id = String(source.id())
const item = current.find((candidate) => candidate.id === id)
if (item === undefined || item.kind === 'shift') {
args.preventDefault()
return
}
const { title, detail } = itemLabel(fields, t)
args.menu.menuTitle = detail === '' ? title : `${title} · ${detail}`
if (item.kind === 'block') {
args.menu.items = [{ text: t.menu.endBlock, onClick: () => run.reopen(id) }]
return
}
const choices: readonly Activity[] = ACTIVITIES_BY_VENUE[venueKind(item.venue)]
const length = minutesBetween(item.start, item.end)
args.menu.items = [
{
text: t.menu.activity,
disabled: choices.length < 2,
items: choices.map((activity) => ({
text: t.activities[activity],
disabled: activity === item.activity,
...(activity === item.activity ? { cssClass: 'cc-menu__current' } : {}),
onClick: () => run.switchActivity(id, activity),
})),
},
{
text: t.menu.length,
// Each length is checked against the venue, the coach and closing time before it is offered.
items: LENGTHS.map((minutes) => {
const issue = minutes === length ? null : lengthIssue(item, minutes, current)
const label = SuperScheduler.Util.escapeHtml(plural(t.minutes, minutes, lang))
const why =
issue === null
? ''
: `<span class="cc-menu__why">${SuperScheduler.Util.escapeHtml(t.menuReasons[issue])}</span>`
return {
html: `<span>${label}</span>${why}`,
disabled: minutes === length || issue !== null,
...(minutes === length ? { cssClass: 'cc-menu__current' } : {}),
onClick: () => run.setLength(id, minutes),
}
}),
},
{ text: '-' },
{ text: t.menu.cancel, cssClass: 'cc-menu__danger', onClick: () => run.cancel(id) },
]
},
})
return {
selection,
booking,
setContext: (next: MenuContext) => {
context = next
},
}
}
export function CourtClubScheduler(props: CourtClubSchedulerProps) {
const { events, items, text, tag, schedulerLocale, initialZoom, controlRef, menuRef, actions } =
props
const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props
const resources = useMemo(() => toResources(text), [text])
const [menus] = useState(createClubMenus)
useLayoutEffect(() => {
menus.setContext({
items,
text,
actions,
tag,
clearSelection: () => controlRef.current?.clearSelection(),
})
}, [menus, items, text, actions, tag, controlRef])
// Hand the booking menu to the demo; close any open menu when the board goes away.
useEffect(() => {
menuRef.current = menus.booking
return () => {
menuRef.current = null
menus.booking.hide()
menus.selection.hide()
}
}, [menus, menuRef])
// Live metrics for the row headers: booked share of each venue, teaching share of each coach.
const metrics = useMemo(() => rowMetrics(items, text), [items, text])
// Header labels follow each cell's span, so they stay right during the animated 30/60 switch.
const headerHook = useMemo(
() =>
createSpanHeaderHook(tag, (span, level) =>
level === 'group' ? 'day' : span <= 30 * MINUTE ? 'half-hour' : 'hour',
),
[tag],
)
const config = useMemo<SchedulerProps>(() => {
const clock = usesTwelveHours(tag) ? 'h:mm tt' : 'HH:mm'
return {
locale: schedulerLocale,
startDate: WEEKEND.start,
days: WEEKEND.days,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// The club is closed at night: only opening hours are on the board. Weekends are business days.
businessBeginsHour: OPENING_HOURS.begin,
businessEndsHour: OPENING_HOURS.end,
businessWeekends: true,
showNonBusiness: false,
useEventBoxes: 'Never',
height: '100%',
rowHeaderWidth: 196,
rowHeaderWidthAutoFit: false,
eventHeight: 30,
rowMarginTop: 4,
rowMarginBottom: 4,
// Theming: one class on the control root carries the club's tokens (./skin.css).
cssClass: 'cc-skin',
allowEventOverlap: false,
conflictHighlight: true,
dragCard: {
duration: false,
dateFormat: clock,
movingDateFormat: clock,
markerFormat: clock,
},
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
contextMenu: menus.booking,
contextMenuSelection: menus.selection,
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
controlRef,
onEventsChange,
onBeforeTimeHeaderRender: headerHook,
onBeforeRowHeaderRender: (args) => {
const id = String(args.row.id)
const name = SuperScheduler.Util.escapeHtml(args.row.name)
const metric = SuperScheduler.Util.escapeHtml(metrics.get(id) ?? '')
const label = SuperScheduler.Util.escapeHtml(text.occupancyLabel)
args.row.html = `<span class="cc-rh"><span class="cc-rh__name">${name}</span><span class="cc-rh__meta" title="${label}">${metric}</span></span>`
const classes = id.startsWith('coach-') ? ['cc-rh--coach'] : []
if (SECTION_STARTS.has(args.row.id)) classes.push('cc-rh--section')
args.row.cssClass = classes.join(' ')
},
onBeforeEventRender: (args) => {
const fields = readClubFields(args.data)
if (fields === null) return
const { title, detail } = itemLabel(fields, text)
const meta =
detail === ''
? ''
: `<span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(detail)}</span>`
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(title)}</span>${meta}</span>`
const classes = ['ss-ev', toneClass(itemTone(fields)), 'cc-ev', `cc-ev--${fields.kind}`]
if (fields.kind === 'booking' && fields.format === 'class') classes.push('cc-ev--class')
if (fields.kind === 'block') classes.push('ss-ev--hatched')
if (fields.kind === 'shift') classes.push('ss-ev--ghost')
args.data.cssClass = classes.join(' ')
},
// Surface and coach rules run on every shadow change; overlap inside a row is the library's.
onEventMoving: (args) => {
const refusal = moveRefusal(
args.e.data,
args.resource,
civilOf(args.start),
civilOf(args.end),
items,
text,
)
if (refusal !== null) {
args.allowed = false
args.message = refusal
} else if (args.conflicts.length > 0) {
args.message = text.rules.occupied
}
onRefused(refusal ?? (args.conflicts.length > 0 ? text.rules.occupied : null))
},
// Final check before the commit (also covers keyboard moves).
onEventMove: (args) => {
const refusal = moveRefusal(
args.e.data,
args.newResource,
civilOf(args.newStart),
civilOf(args.newEnd),
items,
text,
)
if (refusal !== null) {
args.preventDefault()
onRefused(refusal)
}
},
onEventResizing: (args) => {
const refusal = moveRefusal(
args.e.data,
args.e.resource(),
civilOf(args.start),
civilOf(args.end),
items,
text,
)
if (refusal !== null) {
args.allowed = false
args.message = refusal
}
onRefused(refusal)
},
onEventResize: (args) => {
const refusal = moveRefusal(
args.e.data,
args.e.resource(),
civilOf(args.newStart),
civilOf(args.newEnd),
items,
text,
)
if (refusal !== null) {
args.preventDefault()
onRefused(refusal)
}
},
onEventMoved: (args) => onMoved(String(args.e.id())),
onEventResized: (args) => onResized(String(args.e.id())),
onEventClick: (args) => onSelect(String(args.e.id())),
// Coach rows show the rota: nothing is booked on them.
onTimeRangeSelecting: (args) => {
if (!isVenueId(args.resource)) args.allowed = false
},
// Dragging across free time offers the slot's commands straight away.
onTimeRangeSelected: (args) => {
if (args.origin !== 'drag' || !isVenueId(args.resource)) return
menus.selection.show(new SuperScheduler.Selection(args.start, args.end, args.resource), {
owner: args.control,
})
},
onZoom: (args) => {
if (args.phase !== 'end') return
const level = ZOOM_IDS[args.level]
if (level !== undefined) onZoomLevel(level)
},
}
}, [
menus,
controlRef,
headerHook,
initialZoom,
items,
metrics,
onEventsChange,
onMoved,
onRefused,
onResized,
onSelect,
onZoomLevel,
schedulerLocale,
tag,
text,
])
return <SuperSchedulerComponent {...config} resources={resources} events={events} />
}
/**
* Why the club refuses `data` at `resource` from `start` to `end`, or null. Blocks and the rota are
* fixed; a booking needs a surface for its activity and, for a class, a coach on duty and free.
*/
function moveRefusal(
data: object,
resource: SuperScheduler.ResourceId,
start: CivilDateTime,
end: CivilDateTime,
items: readonly ClubItem[],
text: CourtClubText,
): string | null {
const fields = readClubFields(data)
if (fields === null) return null
if (fields.kind !== 'booking') return text.rules.fixed
if (!isVenueId(resource)) return text.rules.coachRow
if (!canHost(resource, fields.activity)) return text.rules.wrongVenue[fields.activity]
if (fields.coach === undefined) return null
const id = 'id' in data ? String(data.id) : undefined
const issue = coachIssue(fields.coach, { ...(id === undefined ? {} : { id }), start, end }, items)
if (issue === 'off-duty') return text.rules.offDuty
if (issue === 'double-booked') return text.rules.doubleBooked
return null
}
/** "11:30 AM – 1:00 PM" / "11:30 – 13:00" in the page's language. */
export function timeRange(start: string, end: string, tag: string): string {
const options: Intl.DateTimeFormatOptions = usesTwelveHours(tag)
? { hour: 'numeric', minute: '2-digit' }
: { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }
return `${formatCivil(start, tag, options)} – ${formatCivil(end, tag, options)}`
}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.