Interventions terrainFieldwork (Entreprise fictive)
Répartition des interventions techniques
Une intervention urgente arrive. Trouvez l’équipe qui peut la prendre avant l’échéance.
- Échelle de temps
- Démo avec données fictives
- heures / jours
- Fonctionnalités Pro
- Glisser-déposer externeFiltresMultisélectionPanneau de détail
SuperScheduler Pro n’est chargé que lorsque vous le demandez.
La situation
Fieldwork est une société fictive de service technique avec six équipes réparties sur trois régions. Chaque équipe a ses métiers (CVC, électricité, plomberie), travaille de 8 h à 18 h et ne couvre que sa région. Les interventions arrivent avec une durée estimée, une zone et une échéance, et certaines sont urgentes.
La matinée du répartiteur, c’est une file et un planning. Poser une intervention sur la mauvaise équipe coûte un déplacement ; dépasser l’échéance rompt un contrat de service. Le planning doit dire, pendant le glisser-déposer, si une équipe peut la prendre et pourquoi pas, et proposer le même contrôle sans souris. Il ne calcule pas d’itinéraires : les trajets restent l’affaire des équipes.
Ce que vous allez faireVous réduirez le planning à un métier, placerez une intervention urgente sur la seule équipe qui peut la faire à temps et renverrez en une fois dans la file les interventions d’une équipe immobilisée.
Où s’arrête la bibliothèque et où commence votre application
SuperScheduler fournit
- Éléments de la page glissés dans le planning avec SuperScheduler.Scheduler.makeDraggable, reçus par onEventMoving et onEventMoved (args.external).
- Vetos à chaque image avec message sur la carte de déplacement, cellules désactivées pour les absences et prévention des chevauchements par équipe.
- Filtrage des lignes avec rows.filter et onRowFilter, les régions restant comme en-têtes.
- Multisélection d’événements (Ctrl/⌘+clic) lue depuis multiselect, déplacements et focus au clavier.
- Échelles jour et semaine avec nuits masquées, et événements contrôlés via onEventsChange.
Le code de cet exemple décide
- Quelle équipe peut faire quelle intervention : métier, région, horaires, absences et échéance.
- La file elle-même : toute intervention absente du planning, si bien que renvoyer des interventions n’est qu’une mise à jour d’état.
- Le panneau Affecter : le premier créneau libre de chaque équipe selon les règles, sans itinéraires ni temps de trajet.
- Le panneau de détail, la mission et tous les textes de cette page.
Le code de cette démo
Ce sont les fichiers exécutés par cette page, pas une copie simplifiée. Ils compilent avec les exports publics de SuperScheduler Pro.
L’intégration : échelles, filtre de lignes, multisélection, absences en cellules désactivées et callbacks qui contrôlent chaque créneau, y compris les dépôts depuis la file.
/**
* 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} />
}Partager ce scénario
Le lien ouvre cet exemple avec la vue actuelle (échelle et options). Il ne contient jamais vos modifications ni de données personnelles.