Zum Inhalt springen
SuperScheduler

MobilitätFleetline (Fiktives Unternehmen)

Planung einer Mietwagenflotte

Ein Kleinwagen fällt am Abholtag aus. Die Miete auf ein anderes Auto legen, die Aufbereitung einhalten und sehen, wo die Flotte knapp wird.

Zeitskala
Demo mit synthetischen Daten
Tage / Wochen
Pro-Funktionen
RessourcenbaumMehrtägige EreignisseStufenloser ZoomMinimapDetailpanel
Fleetline
KleinwagenFL 204SchaltungFL 206AutomatikFL 208SchaltungKombiFL 3117 SitzeFL 3155 SitzeElektroFL E21420 kmFL E24380 kmTransporterFL V029 m³FL V0512 m³FL V0712 m³R-1088R-1101R-1120R-1097R-1126R-1093ScheibenreparaturR-1131R-1090R-1110R-1139R-1104R-1128R-1095R-1115ReifenwechselR-1118R-1099R-1123R-1107R-1112R-1136JahresinspektionR-1114R-111612. Okt.19. Okt.26. Okt.2. Nov.Mo., 12.Di., 13.Mi., 14.Do., 15.Fr., 16.Sa., 17.So., 18.Mo., 19.Di., 20.Mi., 21.Do., 22.Fr., 23.Sa., 24.So., 25.Mo., 26.Di., 27.Mi., 28.Do., 29.Fr., 30.Sa., 31.So., 1.Mo., 2.

SuperScheduler Pro wird erst geladen, wenn Sie es anfordern.

Die Ausgangslage

Fleetline ist eine fiktive Mietstation mit zehn Fahrzeugen: drei Kleinwagen, zwei Kombis, zwei Elektroautos und drei Transporter. Kunden buchen eine Kategorie und einen Zeitraum; die Station entscheidet, welches Fahrzeug rausgeht, und jede Rückgabe braucht Reinigung und Prüfung (bei Elektroautos auch das Laden) vor der nächsten Abholung.

In einer Tabelle bleibt diese Aufbereitung unsichtbar: Der nächste Kunde steht da, während der Transporter noch gereinigt wird. Die Planung muss die Daten jeder Miete festhalten, eine niedrigere Kategorie oder einen Transporter für eine Kleinwagenbuchung ablehnen, die Aufbereitung nach jeder Rückgabe zeigen und erkennen lassen, in welchen Wochen die Flotte fast ausgebucht ist.

Was Sie tun werdenSie legen eine gestrandete Miete auf ein freies Auto (oder ein kostenloses Upgrade), verlängern eine Transportermiete im Detailpanel und finden mit Zoom und Minimap die Woche, in der die ganze Flotte unterwegs ist.

Wo die Bibliothek endet und Ihre Anwendung beginnt

SuperScheduler liefert

  • Kategorien mit ihren Fahrzeugen als Ressourcenbaum, mit Gruppenzeilen, die keine Mieten annehmen.
  • Mehrtägige Balken zu exakten Abhol- und Rückgabezeiten, rein vertikales Verschieben (moveHDisabled) und Ablehnung von Überschneidungen pro Fahrzeug.
  • Zeitverankerte Bereiche in jedem Balken für die Aufbereitung nach der Rückgabe.
  • Tages- und Wochenzoom mit animiertem Wechsel und Strg/⌘ + Mausrad, und eine Minimap, deren Fenster die Planung verschiebt und zoomt.
  • Benachrichtigungen zu Ansicht und Zoom über super-scheduler/hooks, Tastaturverschiebungen und Ansagen.

Der Code dieses Beispiels entscheidet

  • Welches Fahrzeug welche Buchung bedienen darf: dieselbe Kategorie oder ein kostenloses Upgrade vom Kleinwagen zum Kombi.
  • Die Aufbereitungszeit pro Kategorie und die Prüfung, die sie vor der nächsten Abholung frei hält.
  • Die Flottenauslastung, die die Minimap zeichnet, mit ihren gelben und roten Schwellen.
  • Verlängerungen im 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: der Kategorienbaum, Tages- und Wochenzoom, der Aufbereitungsbereich in onBeforeEventRender, die Prüfungen beim Verschieben sowie Minimap und Viewport-Abo, die erst mit dem montierten Control entstehen.

scheduler.tsxtsx
/**
 * Fleetline rental board: vehicles grouped by category over seven weeks, with a fleet-load minimap.
 *
 * Library: resource tree, multi-day bars at exact times, vertical-only moves, overlap refusal with
 * conflict highlight, day/week zoom (buttons, Ctrl/⌘+wheel, the minimap's edges), time-anchored
 * areas for the preparation tail, the minimap widget and viewport subscriptions.
 * Application: which vehicle may serve which booking, how long preparation takes, which rentals
 * clash and what the minimap measures. Those rules live in ./rules.
 */
import { useEffect, useMemo, useRef, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { subscribeScheduler } from 'super-scheduler/hooks'
import { createMinimap, type Minimap } from 'super-scheduler/minimap'
import { format, plural } from '~/i18n/format'
import { DAY, formatCivil, type CivilDateTime } from '../../scheduler/civil'
import { toneClass } from '../../scheduler/tones'
import { createSpanHeaderHook } from '../kit/span-header-hook'
import {
  civilOf,
  isVehicleId,
  itemTone,
  readFleetFields,
  toResources,
  type FleetEvent,
} from './adapter'
import { PERIOD, type FleetItem } from './model'
import { assignment, clashes, fleetLoad, rentalDays } from './rules'
import type { FleetlineText, ZoomId } from './text'
import './fleet.css'

export interface FleetlineSchedulerProps {
  readonly events: FleetEvent[]
  /** The same data read back as the branch's model, for the rules and the minimap. */
  readonly items: readonly FleetItem[]
  readonly text: FleetlineText
  /** 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>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (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
  /** The visible span, published once scrolling or zooming settles. */
  readonly onViewport: (start: CivilDateTime, end: CivilDateTime) => void
}

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'days',
    properties: {
      scale: 'Day',
      cellWidth: 60,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
  {
    id: 'weeks',
    properties: {
      scale: 'Week',
      cellWidth: 150,
      timeHeaders: [{ groupBy: 'Month' }, { groupBy: 'Week' }],
    },
  },
]

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

/** Minimap colours: amber from 75 % of the fleet busy, red from 90 %. */
const loadTone = (value: number) => (value >= 0.9 ? 'danger' : value >= 0.75 ? 'warn' : 'base')

export function FleetlineScheduler(props: FleetlineSchedulerProps) {
  const { events, items, text, tag, schedulerLocale, initialZoom, controlRef } = props
  const { onEventsChange, onSelect, onMoved, onRefused, onZoomLevel, onViewport } = props

  const resources = useMemo(() => toResources(text), [text])
  const clashing = useMemo(() => new Set(clashes(items)), [items])

  // Day cells under week groups, or week cells under month groups (this period has no month shorter than a week).
  const headerHook = useMemo(
    () =>
      createSpanHeaderHook(tag, (span, level) =>
        level === 'group' ? (span > 7 * DAY ? 'month' : 'week') : span > DAY ? 'week' : 'day',
      ),
    [tag],
  )

  const config = useMemo<SchedulerProps>(() => {
    const refusal = (
      data: object,
      resource: SuperScheduler.ResourceId,
      conflicts: number,
    ): string | null => {
      const fields = readFleetFields(data)
      if (fields === null) return null
      if (fields.kind === 'service') return text.rules.fixed
      if (!isVehicleId(resource)) return text.rules.categoryRow
      if (assignment(resource, fields.booked) === null)
        return text.rules.wrongCategory[fields.booked]
      return conflicts > 0 ? text.rules.busy : null
    }

    return {
      locale: schedulerLocale,
      startDate: PERIOD.start,
      days: PERIOD.days,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // The branch plans Monday-to-Sunday weeks, whatever the page language.
      weekStarts: 1,
      height: '100%',
      treeEnabled: true,
      // Category rows group vehicles; nothing is rented to a category row.
      treePreventParentUsage: true,
      rowHeaderWidth: 176,
      rowHeaderWidthAutoFit: false,
      eventHeight: 22,
      rowMarginTop: 3,
      rowMarginBottom: 3,
      // Pickups and returns keep their hour: bars are drawn at exact times, not whole days.
      useEventBoxes: 'Never',
      allowEventOverlap: false,
      conflictHighlight: 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 (!isVehicleId(id)) {
          args.row.cssClass = 'ss-rh--group'
          return
        }
        const plate = SuperScheduler.Util.escapeHtml(args.row.name)
        const spec = SuperScheduler.Util.escapeHtml(text.specs[id])
        args.row.html = `<span class="fl-rh"><span class="fl-rh__plate">${plate}</span><span class="fl-rh__spec">${spec}</span></span>`
      },

      onBeforeEventRender: (args) => {
        const fields = readFleetFields(args.data)
        if (fields === null) return
        const classes = ['ss-ev', 'fl-ev', toneClass(itemTone(fields))]
        if (fields.kind === 'service') {
          args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(text.services[fields.reason])}</span></span>`
          classes.push('ss-ev--hatched')
        } else {
          const resource = args.data.resource
          const upgraded =
            resource !== undefined &&
            isVehicleId(resource) &&
            assignment(resource, fields.booked) === 'upgrade'
          const days = plural(
            text.days,
            rentalDays({ pickup: civilOf(args.data.start), return: fields.returnAt }),
            tag,
          )
          const badge = upgraded
            ? `<span class="ss-evt__badge">${SuperScheduler.Util.escapeHtml(text.upgrade)}</span>`
            : ''
          args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(fields.code)}</span><span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(days)}</span>${badge}</span>`
          // The preparation after the return, drawn as a hatched tail anchored to its times.
          args.data.areas = [
            {
              start: fields.returnAt,
              end: args.data.end,
              top: 0,
              bottom: 0,
              cssClass: 'fl-prep',
              action: 'None',
            },
          ]
          if (clashing.has(String(args.data.id))) classes.push('fl-ev--clash')
        }
        args.data.cssClass = classes.join(' ')
      },

      // Only the row can change (moveHDisabled): check the category; the library checks overlaps.
      onEventMoving: (args) => {
        const message = refusal(args.e.data, args.resource, args.conflicts.length)
        if (message !== null && message !== text.rules.busy) {
          args.allowed = false
          args.message = message
        } else if (message !== null) {
          args.message = message
        }
        onRefused(message)
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const message = refusal(args.e.data, args.newResource, 0)
        if (message !== null) {
          args.preventDefault()
          onRefused(message)
        }
      },

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

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    clashing,
    controlRef,
    headerHook,
    initialZoom,
    onEventsChange,
    onMoved,
    onRefused,
    onSelect,
    onZoomLevel,
    schedulerLocale,
    tag,
    text,
  ])

  // The minimap and the viewport subscription need the mounted control; both go with it.
  const minimapHost = useRef<HTMLDivElement>(null)
  const minimap = useRef<Minimap | null>(null)
  const latestItems = useRef(items)
  useEffect(() => {
    latestItems.current = items
    minimap.current?.refresh()
  }, [items])

  useEffect(() => {
    const control = controlRef.current
    const host = minimapHost.current
    if (control === null || host === null) return
    const date = (value: SuperScheduler.Date) =>
      formatCivil(value.getTime(), tag, { day: 'numeric', month: 'short' })
    const widget = createMinimap(control, host, {
      height: 30,
      peak: 'absolute',
      max: 1,
      // One value per bucket: the share of the fleet that is out, in preparation or in the workshop.
      series: (range) => {
        const values = new Float32Array(range.buckets)
        const start = range.start.getTime()
        for (let index = 0; index < values.length; index++) {
          const from = start + index * range.bucketMs
          values[index] = fleetLoad(latestItems.current, from, from + range.bucketMs)
        }
        return values
      },
      tone: loadTone,
      marks: { today: false, months: true, past: false },
      labels: {
        label: text.minimap.label,
        zoom: text.minimap.zoom,
        valueText: (start, end) => format(text.minimap.range, { from: date(start), to: date(end) }),
      },
    })
    minimap.current = widget
    const unsubscribe = subscribeScheduler(control, 'viewport', (state) =>
      onViewport(civilOf(state.start), civilOf(state.end)),
    )
    return () => {
      unsubscribe()
      widget.dispose()
      minimap.current = null
    }
  }, [controlRef, onViewport, tag, text])

  return (
    <div className="flex h-full flex-col">
      <div className="min-h-0 flex-1 [&>div]:h-full">
        <SuperSchedulerComponent {...config} resources={resources} events={events} />
      </div>
      <div
        ref={minimapHost}
        className="fl-minimap shrink-0 border-t border-line bg-paper-2/60 px-2 py-1.5"
      />
    </div>
  )
}
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 ProblemZimmerplanung im HotelIn Zimmer 104 tropft die Dusche. Buchen Sie den nächsten Gast um, sperren Sie das Zimmer für den Installateur und finden Sie die ausgebuchten Nächte.Zum AnwendungsfallBeherbergung und Vermietung