Skip to content
SuperScheduler

Field serviceFieldwork (Fictional business)

Field service dispatch

An urgent job lands in the queue. Find the crew that can take it before it is due.

Time scale
Demo with synthetic data
hours / days
Pro capabilities
External drag and dropFiltersMulti-selectionDetail panel
Fieldwork
NorthCrew N1HVACCrew N2Elec.CentralCrew C1HVAC · Elec.Crew C2Plumb.SouthCrew S1Plumb. · HVACCrew S2Elec.J-5101 MaintenanceJ-5103 InstallationJ-5114 MaintenanceJ-5102 RepairJ-5104 MaintenanceJ-5105 InspectionJ-5106 InstallationJ-5115 RepairJ-5108 RepairJ-5109 InstallationJ-5117 InstallationJ-5112 MaintenanceJ-5113 RepairJ-5116 InstallationJ-5110 RepairJ-5111 InspectionMonday, October 26Tuesday, October 27Wednesday, October 288 AM9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM8 AM9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM8 AM9 AM

Loads SuperScheduler Pro only when you ask for it.

The situation

Fieldwork is a fictional technical service company with six crews in three regions. Each crew has its trades (HVAC, electrical, plumbing), works from 8 AM to 6 PM and only covers its own region. Jobs come in with an estimate, a service zone and a due time, and some of them are urgent.

A dispatcher’s morning is a queue and a board. Dropping a job on the wrong crew wastes a visit; missing the due time breaks a service agreement. The board has to say, while the job is being dragged, whether a crew can take it and why not, and offer the same check without a mouse. It does not plan routes: travel is the crew’s business.

What you will doYou will narrow the board to one trade, place an urgent job on the only crew that can take it in time, and send a stranded crew’s jobs back to the queue in one action.

Where the library ends and your application begins

SuperScheduler provides

  • Page elements dragged onto the board with SuperScheduler.Scheduler.makeDraggable, arriving through onEventMoving and onEventMoved (args.external).
  • Per-frame vetoes with a message on the drag card, disabled cells for time off, and overlap prevention per crew.
  • Row filtering through rows.filter and onRowFilter, with region rows kept as headings.
  • Event multi-selection (Ctrl/⌘+click) read from multiselect, plus keyboard moves and focus.
  • Day and week scales with nights hidden, and controlled events through onEventsChange.

This example’s code decides

  • Which crew can take which job: trade, region, working hours, time off and due time.
  • The queue itself: every job that is not on the board, so returning jobs is a single state update.
  • The Assign panel: each crew’s first free slot found by the rules, with no routing or travel model.
  • The detail panel, the mission and every label on this page.

The code behind this demo

These are the files this page runs, not a simplified copy. They compile against the public SuperScheduler Pro exports.

The integration: scales, the row filter, multi-selection, time off as disabled cells, and the callbacks that check every slot, drops from the queue included.

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} />
}
Download the example source (.zip)The archive contains this example’s files and a README. It does not include SuperScheduler Pro or any download key.

Share this scenario

The link opens this example with the current view (time scale and options). It never contains your edits or personal data.

A different business, a related problemTraining room schedulingEnrolment outgrew the room. Select both sittings, see what is free for both, move them together and keep the view.Read the business caseField service