Field service dispatch
Technicians are the rows and the day view is the board. Jobs wait in a queue, and each button books the earliest slot any technician has, inside their shift and around lunch, training and the jobs they already have. Booked jobs can be dragged to another technician or resized.
The data model
Section titled “The data model”Three kinds of data, only two of which the scheduler sees:
import type { ResourceInput, SchedulerEvent } from "resource-scheduler";
// Technicians: a shift and the breaks inside it.const technicians: ResourceInput[] = [ { id: "ann", name: "Ann Lee", businessHours: { daysOfWeek: [0, 1, 2, 3, 4, 5, 6], startHour: 7, endHour: 15 }, unavailable: [{ start: new Date(2026, 9, 7, 12), end: new Date(2026, 9, 7, 12, 30) }], // lunch }, // ...];
// Booked jobs: a flat list, one row per job.const jobs: SchedulerEvent[] = [ { id: "j1", title: "Boiler service", resourceId: "ann", startDate: new Date(2026, 9, 7, 8), endDate: new Date(2026, 9, 7, 9, 30) },];
// Jobs waiting for a technician: your own list, not in the scheduler.const queue = [{ id: "q1", title: "No heat, 4 Oak Ave", minutes: 90 }];A job leaves the queue and becomes an event when somebody is chosen. In a database that is a jobs table with a nullable technician_id, starts_at and ends_at: rows without a technician are the queue.
How each rule is expressed
Section titled “How each rule is expressed”| You want | Use |
|---|---|
| A technician’s shift | businessHours, per technician, so shifts can differ |
| Lunch, training, a van in the garage | unavailable ranges, shaded in the day view |
| Jobs that cannot overlap or touch the grey | eventOverlap={false} and blockUnavailable |
| The technician who should take a job | suggestResources: earliest slot, or the least booked |
| How full each technician’s shift is | showUtilization (7.5 of 8 hours, minus lunch) |
| Where “now” is on the board | nowIndicator |
| Jump to the booked slot | the goTo method of the ref |
Assigning a job
Section titled “Assigning a job”suggestResources applies the same rules the grid shows: the technician’s jobs, their unavailable time and their shift. It asks every technician and ranks the answers. The default is the earliest slot, and limit: 1 keeps the winner:
import { addDays } from "date-fns";import { suggestResources, withEvents } from "resource-scheduler";
const assign = (job: { id: string; title: string; minutes: number }) => { const [best] = suggestResources( withEvents(technicians, jobs), { from: new Date(), // never in the past to: addDays(new Date(), 3), // a slot must end by then duration: job.minutes, // starts on the half hour (the default step) }, { limit: 1 } );
if (!best) return alert("Nobody can take it in the next three days"); setJobs((all) => [...all, { id: crypto.randomUUID(), title: job.title, resourceId: best.resourceId, startDate: best.start, endDate: best.end }]); ref.current?.goTo(best.start); // show the day it landed on};Suggestions are free slots only: the candidate’s whole length has to fit inside one shift and miss every other job and break. See Suggest who takes a job for the strategies and the other options.
Make it real
Section titled “Make it real”- Travel time. Book
job.minutes + travelin the search, and draw the event for the on-site part only, or keep the travel as its own event so it shows on the board. - Skills and territories. Keep them in your own data and pass the technicians who qualify:
{ resourceIds: technicians.filter((t) => skills[t.id].includes(job.skill)).map((t) => t.id) }. - Fairness. To spread work out, use
strategy: "least-booked", which ranks by the share of each technician’s shift that is already booked, or a function that mixes start time and utilization. - Check on the server. Two dispatchers can book the same technician at once. Run
suggestResourceswith the fixed time{ start, end }, orisPlacementAllowed, in your API against the saved jobs before committing, and tell the second dispatcher to pick again. - Load only the day on screen. Use
onRangeChangeto fetch the jobs and breaks for the visible range, andwithEventsto merge them when you search.