Zum Inhalt springen
SuperScheduler

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
Morrow
SeminarräumeAtlas24 · 32 Std.Birch16 · 16 Std.Cedar12 · 12 Std.Delta30 · 12 Std.WerkstättenForge10 · 32 Std.Lab14 · 24 Std.TrainerInés40 Std.Owen32 Std.Priya24 Std.Tomás32 Std.VerhandlungFührungVerhandlungFührungOnboardingErste HilfeErste HilfeTabellenPythonPythonDatenkompetenzDatenkompetenzVerhandlungSchweißenStaplerscheinUrlaubAußer HausDienstag, 10. NovemberMittwoch, 11. NovemberDonnerstag, 12. NovemberFreitag, 13. NovemberMontag, 16. November08:0012:0008:0012:0008:0012:0008:0012:0008:0012:00

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().

scheduler.tsxtsx
/**
 * 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,
  )
}
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 ProblemGerätebuchung im LaborWer ein Gerät bucht, bekommt die Kalibrierung mit. Eine Sitzung aus dem Servicefenster holen, dann den eigenen Lauf verlängern.Zum AnwendungsfallGeteilte Räume und Geräte