Dispatch
Schedule field service work orders into technicians' slots on a board, move them from scheduled to done, plan each technician's day as a route, and record the parts each job used.
dispatch is the field service desk. A dispatcher creates a work order at an
asset location or an address, puts it in a technician's slot on the schedule
board, and plans the order the technician drives to their jobs. The
technician opens their day plan, marks each job en route, on site and done,
and records the parts they used.
better-supabase add dispatchimport { createDispatch } from "better-supabase/blocks/dispatch";The module requires tenant and access. With assets a work order and a
part can name an asset location and an asset, with bookings a work order
can take its slot from a booking, and with catalog and inventory a part
can name the product variant and the stock location it came from. With
outbox every change records an event, and with audit the writes are
audited. Technicians are the organization's members who hold
dispatch.work; members hold it by default.
Tables
| Table | Holds |
|---|---|
work_orders | One job: title, priority, status, the technician_id and the slot (starts_at, ends_at), the location_id, asset_id, booking_id, record or address, and the time of each step |
dispatch_routes | A technician's plan for one day |
dispatch_route_stops | The work orders of a route, by position |
work_order_parts | The parts a job used: name, quantity, unit, and the asset_id, variant_id or inventory location_id |
Every table is read through the functions only. Readers with dispatch.read
see every work order, and a technician with dispatch.work sees and works
their own. blocks.dispatch.entities renames the tables and functions.
Create and schedule work orders
const dispatch = createDispatch({ transport, tenant: organizationId });
const order = await dispatch
.create({
title: "Service the boiler",
priority: "high",
locationId,
assetId,
})
.orThrow();
await dispatch
.schedule(order.id, {
technicianId,
startsAt: Temporal.Instant.from("2030-01-07T09:00:00Z"),
})
.orThrow();A work order without a slot waits in the backlog (unscheduled). Pass
technicianId and startsAt to create, or a bookingId to take the slot
from a booking, to schedule it at once. A slot without endsAt lasts
defaultDuration (one hour). The database refuses a slot that overlaps
another scheduled, en route or on site work order of the same technician,
under an advisory lock per technician, so two dispatchers can't book the same
hour. Creating, changing and scheduling need dispatch.manage.
| Method | Returns |
|---|---|
create(input) | the work order, unscheduled or scheduled |
update(id, patch) | the work order with a new title, description, priority or place |
schedule(id, slot) | the work order in the technician's slot; moving it to another technician drops it from the old route |
unschedule(id) | the work order back in the backlog and off its route |
setStatus(id, status, note) | the work order after the step, with note as the completion note or the reason |
get(id) | the work order, or null |
list({ status, technicianId, subject, from, until }) | the organization's work orders, newest first |
board({ from, to, technicianIds }) | the schedule board |
technicianDay(day, technicianId) | the technician's day plan |
planRoute(technicianId, day, ids) | the route with its stops in order |
recordPart(workOrderId, input) | the recorded part |
parts(workOrderId) | the work order's parts, oldest first |
removePart(partId) | true when the part was removed |
The dispatch section lists a record's work orders on
its page, with create and cancel writes.
The status lifecycle
| From | To | Who |
|---|---|---|
scheduled | en_route | the technician or a dispatcher |
scheduled, en_route | on_site | the technician or a dispatcher |
on_site | done | the technician or a dispatcher |
| anything not finished | cancelled | a dispatcher |
Each step stamps its time (enRouteAt, arrivedAt, completedAt,
cancelledAt) and records work_order.status_changed with from and to.
Setting the status a work order already has changes nothing.
The board and the day plan
const board = await dispatch
.board({
from: Temporal.PlainDate.from("2030-01-07"),
to: Temporal.PlainDate.from("2030-01-11"),
})
.orThrow();
const day = await dispatch
.technicianDay(Temporal.PlainDate.from("2030-01-07"))
.orThrow();The board reads 1 to 31 days in the block's timeZone (UTC by default). It
returns the scheduled work orders with their day, the technicians (the ones
you pass, else the ones with work), and the backlog, most urgent first.
planRoute sets the order a technician drives to their work orders on a
day; each must be a live work order of theirs. technicianDay returns the
route's stops in that order, then the day's other work orders by start.
technicianId defaults to the caller, so a technician opens their own plan.
A dispatcher plans any route; a technician plans their own.
blocks: {
dispatch: { options: { timeZone: "Europe/Amsterdam", defaultDuration: "90 minutes" } },
},Parts
await dispatch
.recordPart(order.id, {
name: "Gasket",
quantity: 2,
unit: "pc",
variantId,
locationId,
})
.orThrow();A part is free text, an asset, or a catalog variant taken from an inventory location. Recording a part moves no stock: book the movement in the inventory block when the job is done. The technician on the work order and dispatchers record parts, and the one who recorded a part can remove it.
UI recipes
The dispatch-board UI recipe renders the board
as a kanban with the backlog as the first column and a column per
technician, and technician-day renders a technician's route as a list with
the status buttons. Both are in the Next.js example.
Errors
| Hint | Cause |
|---|---|
DISPATCH_FORBIDDEN | the caller lacks dispatch.read or dispatch.manage, or can't plan this route |
DISPATCH_NOT_FOUND | no work order with this id that the caller manages or works |
DISPATCH_INVALID | the title isn't 1 to 200 characters, or the priority is unknown |
DISPATCH_SUBJECT_INVALID | only one of the record type and id was passed |
DISPATCH_SUBJECT_NOT_FOUND | the record doesn't exist in the organization |
DISPATCH_TECHNICIAN_REQUIRED | a slot was passed without a technician |
DISPATCH_TECHNICIAN_INVALID | the technician isn't a member with dispatch.work |
DISPATCH_SLOT_INVALID | the slot doesn't end after it starts |
DISPATCH_SLOT_TAKEN | the technician already has a work order in that slot |
DISPATCH_STATUS_INVALID | the work order has started, or is cancelled |
DISPATCH_TRANSITION_INVALID | the step isn't in the lifecycle |
DISPATCH_RANGE_INVALID | the board spans more than 31 days |
DISPATCH_ROUTE_INVALID | a stop repeats, or isn't a live work order of the technician |
DISPATCH_PART_INVALID | the part has no name or a quantity of zero or less |
DISPATCH_LOCATION_NOT_FOUND | the location doesn't exist in the organization |
DISPATCH_ASSET_NOT_FOUND | the asset doesn't exist in the organization |
DISPATCH_BOOKING_NOT_FOUND | the booking doesn't exist in the organization |
DISPATCH_VARIANT_NOT_FOUND | the product variant doesn't exist in the organization |
DISPATCH_INVENTORY_LOCATION_NOT_FOUND | the inventory location doesn't exist in the organization |
DISPATCH_ASSETS_MISSING | a location or asset was named without the assets block |
DISPATCH_BOOKINGS_MISSING | a booking was named without the bookings block |
DISPATCH_CATALOG_MISSING | a variant was named without the catalog block |
DISPATCH_INVENTORY_MISSING | an inventory location was named without the inventory block |
Events
work_order.created, work_order.updated, work_order.scheduled,
work_order.unscheduled, work_order.status_changed,
work_order.part_used and dispatch_route.planned. The
events page lists their payloads.
Last updated on
Billable time
Price tracked time with an hourly rate card per organization, member and record, preview what it would bill, and turn it into draft invoice lines in bigint minor units.
Shipments
Ship packages between addresses, buy labels through a versioned carrier interface, and keep a tracking timeline that a signed carrier webhook updates without duplicates.