Zum Inhalt springen
SuperScheduler

LaborBenchlab (Fiktives Unternehmen)

Gerätebuchung im Labor

Wer ein Gerät bucht, bekommt die Kalibrierung mit. Eine Sitzung aus dem Servicefenster holen, dann den eigenen Lauf verlängern.

Zeitskala
Demo mit synthetischen Daten
Stunden / Tage
Pro-Funktionen
React-Render-SlotsGesperrte ZeitenVerschieberegelnDetailpanel
Benchlab
MikroskopieKonfokalmikroskopWeitfeldmikroskopAnalytikLC-MSUV-Vis-SpektralphotometerMolekularbiologieqPCR-Cycler 1qPCR-Cycler 2SequenziererArbeitsplätzeNasslabor-Platz 1Nasslabor-Platz 2SicherheitswerkbankCB-091CB-088PX-220PX-214CB-088GN-140TL-02CB-091GN-140Mittwoch, 21. OktoberDonnerstag, 22. Oktober07:0008:0009:0010:0011:0012:0013:0014:0015:0016:0017:0018:0019:0007:0008:0009:0010:0011:0012:0013:0014:0015:00

SuperScheduler Pro wird erst geladen, wenn Sie es anfordern.

Die Ausgangslage

Benchlab ist ein fiktives Gemeinschaftslabor: zwei Mikroskope, ein LC-MS, ein Spektralphotometer, qPCR-Cycler, ein Sequenzierer und Arbeitsplätze für vier Forschungsgruppen. Die meisten Geräte brauchen Vorlauf: Der Laser des Konfokalmikroskops wärmt sich auf, das LC-MS kalibriert eine Stunde, der Sequenzierer primt seine Flusszelle.

Wenn eine Buchung nur den Lauf erfasst, frisst die Vorbereitung den vorherigen Slot, und der Servicebesuch des Herstellers landet auf einer Sitzung, die niemand verschoben hat. Das Board muss die Vorbereitung als Teil der Buchung behandeln, nicht verfügbare Zeit unerreichbar machen und klar sagen, warum eine Anfrage nicht passt. Die Regeln sind die eigene Buchungspolitik dieses Labors, kein Compliance-System.

Was Sie tun werdenSie holen eine Sitzung aus einem Servicefenster, buchen zwei Stunden LC-MS mit automatisch vorangestellter Kalibrierung und verlängern diesen Lauf, ohne dass sich die Kalibrierung ändert.

Wo die Bibliothek endet und Ihre Anwendung beginnt

SuperScheduler liefert

  • Zoomstufen nach Stunden und Tagen, Nächte und Wochenenden ausgeblendet.
  • Deaktivierte Zellen, die nicht verfügbare Stunden schraffieren und Ablegen verhindern, gesetzt in onBeforeCellRender.
  • Zeitlich verankerte Ereignisbereiche (areas), mit denen die Vorbereitung in jeder Buchung gezeichnet wird.
  • Zeitbereiche per Ziehen auswählen, Ziehen und Dauer ändern ohne Überschneidung, Ablehnungsmeldungen auf der Drag-Karte, Rückgängig und Wiederholen mit kontrollierten Ereignissen und protokollierten Neuanlagen.
  • React-Zeilenköpfe (renderRowHeader) und Buchungsbalken (renderEvent) aus super-scheduler/react-render.

Der Code dieses Beispiels entscheidet

  • Wie lange jedes Gerät Vorlauf braucht und wie aus einem gewählten Lauf eine Buchung mit vorangestellter Vorbereitung wird.
  • Welche Änderungen erlaubt sind: nur zwischen identischen Geräten, nie über Service oder Wartung, Läufe ab 30 Minuten, Vorbereitung fest am Anfang.
  • Die Service- und Wartungsfenster und die Buchungen, mit denen sie kollidieren.
  • Das Buchungspanel, die Mission und alle Texte dieser Seite. Keine regulatorischen oder Audit-Versprechen.

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: Zoomstufen, deaktivierte Zellen für nicht verfügbare Zeit, der Vorbereitungsbereich jeder Buchung, Buchen per Ziehen und die Callbacks, in denen die Regeln eine Verschiebung oder Änderung der Dauer ablehnen.

scheduler.tsxtsx
/**
 * Benchlab instrument board: shared instruments and benches over one week, preparation included.
 *
 * Library: timeline with hidden nights, hour and day zoom levels, tree rows, disabled cells for
 * unavailable time, time-anchored event areas for the preparation, drag-to-book range selection,
 * drag/resize with overlap prevention, undo history, keyboard, and React content (row headers and
 * booking bars) through `super-scheduler/react-render`.
 * Application: how long each instrument prepares, where a booking may go, unavailable windows and
 * what a new booking needs in front of its run. Those rules live in ./rules and run in the callbacks.
 */
import { useEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import { format, plural } from '~/i18n/format'
import { parseCivil } from '../../scheduler/civil'
import { usesTwelveHours } from '../../scheduler/header-format'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
  GROUP_TONES,
  civilOf,
  isInstrumentId,
  readBookingFields,
  toResources,
  type BookingEvent,
} from './adapter'
import { INITIAL_VIEW_START, LAB_HOURS, WEEK, type Block, type InstrumentId } from './model'
import { blockAt, instrumentOf, moveProblem, type MoveProblem } from './rules'
import { BookingBar, InstrumentHeader } from './instrument-slots'
import type { BenchlabText, ZoomId } from './text'
import './lab.css'

export interface BenchlabSchedulerProps {
  readonly events: BookingEvent[]
  readonly blocks: readonly Block[]
  /** Bookings that overlap unavailable time. */
  readonly collisions: readonly string[]
  readonly text: BenchlabText
  /** 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 history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  /** A run dragged across free time: the application plans the booking around it. */
  readonly onRequest: (instrument: InstrumentId, start: string, end: string) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onResized: (id: string) => void
  readonly onRefused: (message: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'hours',
    properties: {
      scale: 'Hour',
      cellWidth: 56,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
    },
  },
  {
    id: 'days',
    properties: {
      scale: 'Day',
      cellWidth: 180,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['hours', 'days']

export function BenchlabScheduler(props: BenchlabSchedulerProps) {
  const {
    events,
    blocks,
    collisions,
    text,
    tag,
    schedulerLocale,
    initialZoom,
    history,
    controlRef,
  } = props
  const { onEventsChange, onRequest, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])
  // Header labels follow the active level. An animated zoom renders the new level's headers before
  // control.scale reports it, so the hook is rebuilt (headers repainted) once a zoom lands.
  const [headerHook, setHeaderHook] = useState(() => createHeaderHook(tag))
  // Narrower row headers on phones (no icons, smaller indent), chosen once on mount.
  const compact = useMemo(() => window.matchMedia('(max-width: 639px)').matches, [])

  const config = useMemo<SchedulerProps>(() => {
    const colliding = new Set(collisions)
    const pattern = usesTwelveHours(tag) ? 'h:mm tt' : 'HH:mm'
    const refusal = (problem: MoveProblem) =>
      problem === 'other-model'
        ? text.rules.otherModel
        : problem === 'unavailable'
          ? text.rules.unavailable
          : text.rules.tooShort

    return {
      locale: schedulerLocale,
      startDate: WEEK.start,
      days: WEEK.days,
      weekStarts: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // The lab is open 07:00–20:00: nights stay hidden on the hour level.
      businessBeginsHour: LAB_HOURS.begin,
      businessEndsHour: LAB_HOURS.end,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: compact ? 144 : 216,
      rowHeaderWidthAutoFit: false,
      treeIndent: compact ? 10 : 20,
      eventHeight: 30,
      rowMarginTop: 3,
      rowMarginBottom: 3,
      allowEventOverlap: false,
      // Selecting free time (pointer drag or keyboard) asks for a booking; plain clicks do not.
      timeRangeSelectedHandling: 'Enabled',
      dragCard: {
        duration: false,
        dateFormat: pattern,
        movingDateFormat: pattern,
        markerFormat: pattern,
      },
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: headerHook,

      onBeforeRowHeaderRender: (args) => {
        args.row.cssClass = args.row.data.children !== undefined ? 'ss-rh--group' : ''
      },

      // Unavailable time: hour cells inside a window are disabled (hatched, drops refused by the
      // library); a day cell that only partly overlaps one is tinted.
      onBeforeCellRender: (args) => {
        const instrument = args.cell.resource
        if (!isInstrumentId(instrument)) return
        const start = args.cell.start.value
        const end = args.cell.end.value
        const block = blockAt(instrument, start, end, blocks)
        if (block === undefined) return
        if (
          parseCivil(block.start) <= parseCivil(start) &&
          parseCivil(end) <= parseCivil(block.end)
        ) {
          args.cell.properties.disabled = true
          return
        }
        args.cell.properties.cssClass = 'bl-unavailable'
        args.cell.properties.html = `<span class="block truncate px-1.5 pt-1 text-[11px] text-ink-3">${SuperScheduler.Util.escapeHtml(text.blocks[block.kind])}</span>`
      },

      onBeforeEventRender: (args) => {
        const fields = readBookingFields(args.data)
        if (fields === null) return
        // HTML fallback shown until the React bar (renderEvent) mounts.
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(fields.project)}</span></span>`
        const classes = ['ss-ev', toneClass(GROUP_TONES[fields.group])]
        if (colliding.has(String(args.data.id)))
          classes.push('ss-ev--late', 'outline-2', 'outline-offset-1', 'outline-danger')
        args.data.cssClass = classes.join(' ')
        // The preparation is drawn by the library as an area anchored in time at the booking start.
        args.data.areas =
          fields.prepMinutes === 0
            ? []
            : [
                {
                  start: args.data.start,
                  end: args.data.start.addMinutes(fields.prepMinutes),
                  top: 0,
                  bottom: 0,
                  cssClass: 'bl-prep',
                  action: 'None',
                },
              ]
      },

      // Identical instruments only, never across unavailable time; overlap is refused by the library.
      onEventMoving: (args) => {
        const fields = readBookingFields(args.e.data)
        const from = args.e.resource()
        if (fields === null || !isInstrumentId(from) || !isInstrumentId(args.resource)) return
        const problem = moveProblem(
          { instrument: from, prepMinutes: fields.prepMinutes },
          args.resource,
          args.start.value,
          args.end.value,
          blocks,
        )
        if (problem !== null) {
          args.allowed = false
          args.message = refusal(problem)
        }
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const fields = readBookingFields(args.e.data)
        const from = args.e.resource()
        if (fields === null || !isInstrumentId(from) || !isInstrumentId(args.newResource)) return
        const problem = moveProblem(
          { instrument: from, prepMinutes: fields.prepMinutes },
          args.newResource,
          args.newStart.value,
          args.newEnd.value,
          blocks,
        )
        if (problem !== null) {
          args.preventDefault()
          onRefused(refusal(problem))
        }
      },

      // The preparation belongs to the instrument: only the end of a booking can be dragged.
      onEventResizing: (args) => {
        const fields = readBookingFields(args.e.data)
        const instrument = args.e.resource()
        if (fields === null || !isInstrumentId(instrument)) return
        const problem =
          args.what === 'start'
            ? null
            : moveProblem(
                { instrument, prepMinutes: fields.prepMinutes },
                instrument,
                args.start.value,
                args.end.value,
                blocks,
              )
        if (args.what === 'start' || problem !== null) {
          args.allowed = false
          args.message = problem === null ? text.rules.startEdge : refusal(problem)
        }
      },

      onEventResize: (args) => {
        const fields = readBookingFields(args.e.data)
        const instrument = args.e.resource()
        if (fields === null || !isInstrumentId(instrument)) return
        const problem = moveProblem(
          { instrument, prepMinutes: fields.prepMinutes },
          instrument,
          args.newStart.value,
          args.newEnd.value,
          blocks,
        )
        if (args.what === 'start' || problem !== null) {
          args.preventDefault()
          onRefused(problem === null ? text.rules.startEdge : refusal(problem))
        }
      },

      onTimeRangeSelected: (args) => {
        args.control.clearSelection()
        if (args.origin === 'click' || !isInstrumentId(args.resource)) return
        onRequest(args.resource, civilOf(args.start), civilOf(args.end))
      },

      onEventMoved: (args) => onMoved(String(args.e.id())),
      onEventResized: (args) => onResized(String(args.e.id())),
      onEventClick: (args) => onSelect(String(args.e.id())),

      onZoom: (args) => {
        if (args.phase !== 'end') return
        setHeaderHook(() => createHeaderHook(tag))
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    history,
    controlRef,
    blocks,
    collisions,
    compact,
    onEventsChange,
    onMoved,
    onRefused,
    onRequest,
    onResized,
    onSelect,
    onZoomLevel,
    schedulerLocale,
    headerHook,
    tag,
    text,
    initialZoom,
  ])

  // React row headers: instruments show how long they prepare before every run.
  const renderRowHeader = useMemo<NonNullable<SchedulerProps['renderRowHeader']>>(
    () =>
      ({ row }) => {
        const id = row.id
        if (!isInstrumentId(id)) return row.name
        const instrument = instrumentOf(id)
        const minutes = instrument.prep.minutes
        return (
          <InstrumentHeader
            name={text.instruments[id]}
            area={compact ? null : instrument.area}
            prep={
              minutes === 0
                ? text.noPrep
                : format(text.prepChip, {
                    minutes: plural(text.minutes, minutes, tag),
                    kind: text.prepKinds[instrument.prep.kind],
                  })
            }
          />
        )
      },
    [text, tag, compact],
  )

  // React booking bars: project, group and sample count; a warning icon when the time is unavailable.
  const renderEvent = useMemo<NonNullable<SchedulerProps['renderEvent']>>(
    () =>
      ({ e, data, width }) => {
        const fields = readBookingFields(data)
        if (fields === null) return e.text()
        return (
          <BookingBar
            project={fields.project}
            group={text.groups[fields.group]}
            samples={plural(text.samples, fields.samples, tag)}
            unavailable={collisions.includes(String(e.id()))}
            compact={width < 176}
          />
        )
      },
    [collisions, text, tag],
  )

  // Imperative and once: a scroll prop would be re-applied on every update.
  useEffect(() => {
    controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
  }, [controlRef])

  return (
    <SuperSchedulerComponent
      {...config}
      resources={resources}
      events={events}
      renderRowHeader={renderRowHeader}
      renderEvent={renderEvent}
    />
  )
}
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 ProblemPlanung von SchulungsräumenDie Anmeldungen sprengen den Raum. Beide Termine wählen, sehen, was für beide frei ist, zusammen verschieben und die Ansicht behalten.Zum AnwendungsfallGeteilte Räume und Geräte