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
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.
/**
* 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} />
}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.