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
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.
/**
* 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} />
}Share this scenario
The link opens this example with the current view (time scale and options). It never contains your edits or personal data.