Zum Inhalt springen
SuperScheduler

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
Court Club
Platz 1 · Sand17 %Platz 2 · Sand18 %Platz 3 · Hartplatz18 %Padel 125 %Padel 220 %Padel 315 %Studio A · Bikes10 %Studio B · Matten10 %Coach Iris50 %Coach Mateo30 %Coach Noor59 %Linien kehrenTennis · IrisTennis · MitgliederbuchungTennis · IrisLinien kehrenTennis · MitgliederbuchungTennis · MateoTennis · MitgliederbuchungTennis · MitgliederbuchungPickleball · MitgliederbuchungTennis · MateoPadel · MitgliederbuchungPadel · NoorPadel · MitgliederbuchungPadel · MitgliederbuchungPadel · MitgliederbuchungPadel · NoorPadel · MitgliederbuchungPadel · MitgliederbuchungPadel · MitgliederbuchungCycling · IrisCycling · NoorYoga · IrisPilates · NoorIm DienstIm DienstIm DienstIm DienstIm DienstSamstag, 17. Oktober07:0007:3008:0008:3009:0009:3010:0010:3011:0011:3012:0012:3013:0013:3014:0014:3015:0015:3016:0016:3017:0017:3018:00

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.

scheduler.tsxtsx
/**
 * 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)}`
}
Quellcode des Beispiels herunterladen (.zip)Das Archiv enthält die Dateien dieses Beispiels und eine README. SuperScheduler Pro und Download-Schlüssel sind nicht enthalten.

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.

Eine andere Branche, ein verwandtes ProblemTermine in der PhysiotherapiepraxisEin Patient kann um 10 Uhr nicht. Finden Sie den nächsten Slot, der Pausen und Reinigung respektiert.Zum AnwendungsfallSpielstätten und Veranstaltungen