Zum Inhalt springen
SuperScheduler

AußendienstFieldwork (Fiktives Unternehmen)

Disposition im technischen Außendienst

Ein dringender Auftrag kommt herein. Finden Sie das Team, das ihn rechtzeitig übernehmen kann.

Zeitskala
Demo mit synthetischen Daten
Stunden / Tage
Pro-Funktionen
Externes Drag & DropFilterMehrfachauswahlDetailpanel
Fieldwork
NordTeam N1HLKTeam N2ElektroMitteTeam C1HLK · ElektroTeam C2SanitärSüdTeam S1Sanitär · HLKTeam S2ElektroJ-5101 WartungJ-5103 InstallationJ-5114 WartungJ-5102 ReparaturJ-5104 WartungJ-5105 PrüfungJ-5106 InstallationJ-5115 ReparaturJ-5108 ReparaturJ-5109 InstallationJ-5117 InstallationJ-5112 WartungJ-5113 ReparaturJ-5116 InstallationJ-5110 ReparaturJ-5111 PrüfungMontag, 26. OktoberDienstag, 27. OktoberMittwoch, 28. Oktober08:0009:0010:0011:0012:0013:0014:0015:0016:0017:0008:0009:0010:0011:0012:0013:0014:0015:0016:0017:0008:0009:00

SuperScheduler Pro wird erst geladen, wenn Sie es anfordern.

Die Ausgangslage

Fieldwork ist ein fiktiver technischer Kundendienst mit sechs Teams in drei Regionen. Jedes Team hat seine Gewerke (HLK, Elektro, Sanitär), arbeitet von 08:00 bis 18:00 Uhr und deckt nur seine Region ab. Aufträge kommen mit Zeitschätzung, Einsatzzone und Frist herein, manche davon dringend.

Der Vormittag in der Disposition besteht aus einer Warteschlange und einer Tafel. Ein Auftrag beim falschen Team kostet eine Anfahrt, eine verpasste Frist bricht einen Servicevertrag. Die Tafel muss schon beim Ziehen sagen, ob ein Team den Auftrag übernehmen kann und warum nicht, und dieselbe Prüfung auch ohne Maus anbieten. Routen plant sie nicht: Die Anfahrt organisiert das Team.

Was Sie tun werdenSie schränken die Tafel auf ein Gewerk ein, legen einen dringenden Auftrag auf das einzige Team, das ihn rechtzeitig schafft, und geben die Aufträge eines ausgefallenen Teams in einem Schritt an die Warteschlange zurück.

Wo die Bibliothek endet und Ihre Anwendung beginnt

SuperScheduler liefert

  • Seitenelemente, die mit SuperScheduler.Scheduler.makeDraggable auf die Tafel gezogen werden und über onEventMoving und onEventMoved (args.external) ankommen.
  • Vetos pro Frame mit Meldung auf der Drag-Karte, deaktivierte Zellen für Abwesenheiten und Überlappungsschutz pro Team.
  • Zeilenfilter über rows.filter und onRowFilter, Regionen bleiben als Überschriften stehen.
  • Mehrfachauswahl von Ereignissen (Strg/⌘+Klick) aus multiselect, dazu Tastaturbedienung und Fokus.
  • Tages- und Wochenskala mit ausgeblendeten Nächten und kontrollierte Ereignisse über onEventsChange.

Der Code dieses Beispiels entscheidet

  • Welches Team welchen Auftrag übernehmen kann: Gewerk, Region, Arbeitszeit, Abwesenheit und Frist.
  • Die Warteschlange selbst: jeder Auftrag, der nicht auf der Tafel liegt. Aufträge zurückzugeben ist deshalb ein einziges State-Update.
  • Das Zuweisen-Panel: der erste freie Slot jedes Teams nach den Regeln, ohne Routen- oder Fahrzeitmodell.
  • Das Detailpanel, die Aufgabe 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: Skalen, Zeilenfilter, Mehrfachauswahl, Abwesenheiten als deaktivierte Zellen und die Callbacks, die jeden Slot prüfen, auch beim Ablegen aus der Warteschlange.

scheduler.tsxtsx
/**
 * Fieldwork dispatch board: crews grouped by region over one working week. Jobs arrive from the
 * queue below the board (an external drag, see ./tray) or move between crews on the board.
 *
 * Library: tree rows, row filtering, external drops, drag between rows with per-frame vetoes,
 * event multi-selection, disabled cells, day and week scales, keyboard moves.
 * Application: which crew can take which job (skill, region, working hours, availability, due
 * time). Those rules live in ./rules; nothing here plans routes or travel.
 */
import { useEffect, useMemo, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { civil } from '../../scheduler/civil'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import { useDropRefusal } from '../kit/use-drop-refusal'
import {
  SKILL_TONES,
  crewOf,
  readJobFields,
  toBooking,
  toResources,
  type JobEvent,
} from './adapter'
import { BLOCKS, SKILLS, WEEK, WORKING_HOURS, type Skill } from './model'
import { slotRefusal, type Booking, type Refusal } from './rules'
import { ZOOM_IDS, type FieldworkText, type SkillFilter, type ZoomId } from './text'

export interface FieldworkSchedulerProps {
  readonly events: JobEvent[]
  readonly text: FieldworkText
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  /** Scale on mount. Later changes go through control.zoom so the view animates. */
  readonly initialZoom: ZoomId
  /** Crews shown: every crew, or those with one skill. */
  readonly filter: SkillFilter
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  /** The selected jobs, after every click, Ctrl/⌘+click or keyboard selection. */
  readonly onSelection: (ids: readonly string[]) => void
  /** `external`: the job came from the queue. */
  readonly onMoved: (id: string, external: boolean) => void
  readonly onRefused: (id: string, refusal: Refusal) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

/** A working day in hours, or the week in two-hour cells. */
const SCALES: Readonly<Record<ZoomId, SuperScheduler.ZoomLevelProperties>> = {
  day: { scale: 'Hour', cellWidth: 60, timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }] },
  week: {
    scale: 'CellDuration',
    cellDuration: 120,
    cellWidth: 48,
    timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
  },
}

// Same order as ZOOM_IDS, so onZoom's level index maps back to an id.
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = ZOOM_IDS.map((id) => ({
  id,
  properties: SCALES[id],
}))

function isSkill(value: unknown): value is Skill {
  return typeof value === 'string' && (SKILLS as readonly string[]).includes(value)
}

/** Every job the control holds right now, as bookings the rules read. */
function bookingsIn(control: SuperScheduler.Scheduler): Booking[] {
  return control.events.list.flatMap((event) => toBooking(event) ?? [])
}

/** Checks one proposed slot of a job (on the board or dragged from the queue) for a crew. */
function refusalFor(
  control: SuperScheduler.Scheduler | null,
  data: object,
  id: string,
  resource: SuperScheduler.ResourceId,
  start: SuperScheduler.Date,
  end: SuperScheduler.Date,
): Refusal | null {
  const fields = readJobFields(data)
  const crew = crewOf(resource)
  if (control === null || fields === null || crew === null) return null
  return slotRefusal(
    { id, ...fields },
    crew,
    civil(start.ticks),
    civil(end.ticks),
    WORKING_HOURS,
    BLOCKS,
    bookingsIn(control),
  )
}

export function FieldworkScheduler(props: FieldworkSchedulerProps) {
  const { events, text, tag, schedulerLocale, initialZoom, filter, controlRef } = props
  const { onEventsChange, onSelection, onMoved, onRefused, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])
  const refusal = useDropRefusal(onRefused)

  const config = useMemo<SchedulerProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const selectionOf = (control: SuperScheduler.Scheduler) =>
      control.multiselect.events().map((event) => String(event.id()))

    return {
      locale: schedulerLocale,
      startDate: WEEK.start,
      days: WEEK.days,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Crews are on the road 08:00-18:00: evenings and nights are not drawn.
      businessBeginsHour: WORKING_HOURS.begin,
      businessEndsHour: WORKING_HOURS.end,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: 176,
      rowHeaderWidthAutoFit: false,
      eventHeight: 28,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      allowEventOverlap: false,
      // Time on site is the job's estimate: dispatchers move jobs, they do not stretch them.
      eventResizeHandling: 'Disabled',
      // A click selects a job; Ctrl/⌘+click adds more for a bulk action.
      eventClickHandling: 'Select',
      allowMultiSelect: true,
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      dragCard: { duration: false },
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: createHeaderHook(tag),

      onBeforeRowHeaderRender: (args) => {
        const crew = crewOf(args.row.id)
        if (crew === null) {
          args.row.cssClass = 'ss-rh--group'
          return
        }
        const skills = crew.skills.map((skill) => text.skillsShort[skill]).join(' · ')
        args.row.html =
          `<span class="flex w-full min-w-0 items-center justify-between gap-2">` +
          `<span class="shrink-0 font-medium">${escape(args.row.name)}</span>` +
          `<span class="truncate text-[11px] text-ink-3">${escape(skills)}</span></span>`
      },

      // The skill filter hides crews without that skill; region rows stay as headings.
      onRowFilter: (args) => {
        const crew = crewOf(args.row.id)
        if (crew !== null && isSkill(args.filterParam))
          args.visible = crew.skills.includes(args.filterParam)
      },

      // Training and vehicle service: disabled cells refuse drops for that crew.
      onBeforeCellRender: (args) => {
        const crew = crewOf(args.cell.resource)
        if (crew === null) return
        const start = civil(args.cell.start.ticks)
        const block = BLOCKS.find(
          (candidate) =>
            candidate.crew === crew.id &&
            candidate.start < civil(args.cell.end.ticks) &&
            start < candidate.end,
        )
        if (block === undefined) return
        args.cell.properties.disabled = true
        if (start <= block.start) {
          // The engine clips cells with an inline style; the important utility lets the label of
          // the block's first cell run over the rest of the block.
          args.cell.properties.cssClass = 'overflow-visible!'
          args.cell.properties.html = `<span class="relative z-[1] block whitespace-nowrap px-1.5 pt-0.5 text-[10.5px] font-semibold text-ink-3">${escape(text.blocks[block.kind])}</span>`
        }
      },

      onBeforeEventRender: (args) => {
        const fields = readJobFields(args.data)
        if (fields === null) return
        // The job code always shows; the job type gives way when the bar is short.
        const urgent = fields.priority === 'urgent' ? `<span class="ss-evt__badge">!</span>` : ''
        args.data.html =
          `<span class="flex min-w-0 items-baseline gap-1.5 whitespace-nowrap">${urgent}` +
          `<span class="shrink-0">${escape(String(args.data.id))}</span>` +
          `<span class="truncate font-medium opacity-75">${escape(text.types[fields.type])}</span></span>`
        const classes = ['ss-ev', toneClass(SKILL_TONES[fields.skill])]
        // Selected jobs are outlined here: without a `theme` the control adds no class of its own
        // to selected events. The selection redraw re-runs this hook for the jobs that changed.
        const self = args.control.events.find(args.data.id)
        if (self !== null && args.control.multiselect.isSelected(self))
          classes.push('z-[2] outline-2 outline-offset-1 outline-ink')
        args.data.cssClass = classes.join(' ')
      },

      // Runs for moves on the board and for jobs dragged in from the queue (args.external).
      onEventMoving: (args) => {
        const id = String(args.e.id())
        const reason = refusalFor(
          controlRef.current,
          args.e.data,
          id,
          args.resource,
          args.start,
          args.end,
        )
        refusal.track(id, reason)
        if (reason !== null) {
          args.allowed = false
          args.message = text.rules[reason]
        }
      },

      // Final check before the commit; keyboard moves (Alt+arrows) arrive here without a drag.
      onEventMove: (args) => {
        const id = String(args.e.id())
        const reason = refusalFor(
          args.control,
          args.e.data,
          id,
          args.newResource,
          args.newStart,
          args.newEnd,
        )
        if (reason !== null) {
          args.preventDefault()
          refusal.clear()
          onRefused(id, reason)
        }
      },

      onEventMoved: (args) => {
        refusal.clear()
        onMoved(String(args.e.id()), args.external)
      },
      onEventSelected: () => {
        const control = controlRef.current
        if (control !== null) onSelection(selectionOf(control))
      },

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

  // Row filtering is imperative: the control re-runs onRowFilter for every row.
  useEffect(() => {
    controlRef.current?.rows.filter(filter === 'all' ? null : filter)
  }, [controlRef, filter])

  return <SuperSchedulerComponent {...config} resources={resources} events={events} />
}
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 AnwendungsfallAußendienst