The ops tech module at /ops/#/j/* had drifted from the field app in two ways:
1. Scanner — a prior "restoration" re-added html5-qrcode, but the
design has always been native <input capture="environment"> → Gemini
2.5 Flash via targo-hub /vision/barcodes (up to 3 codes) and
/vision/equipment (structured labels, up to 5). Revert useScanner.js
+ ScanPage.vue + TechScanPage.vue to commit e50ea88 and drop
html5-qrcode from both package.json + lockfiles. No JS barcode
library, no camera stream, no polyfills.
2. Equipment UX — TechJobDetailPage.vue was a 186-line stub missing the
Ajouter bottom-sheet (Scanner / Rechercher / Créer), the debounced
SN-then-MAC search, the 5-field create dialog, Type + Priority
selects on the info card, and the location-detail contact expansion.
Port the full UX from apps/field/src/pages/JobDetailPage.vue (526
lines) into the ops module (458 lines after consolidation).
Rebuilt and deployed both apps. Remote smoke test confirms 0 bundles
reference html5-qrcode and the new TechJobDetailPage.1075b3b8.js chunk
(16.7 KB vs ~5 KB stub) ships the equipment bottom-sheet strings.
Docs:
- docs/features/tech-mobile.md — new. Documents all three delivery
surfaces (legacy SSR /t/{jwt}, transitional apps/field/, unified
/ops/#/j/*), Gemini-native scanner pipeline, equipment UX, magic-link
JWT, cutover plan. Replaces an earlier stub that incorrectly
referenced html5-qrcode.
- docs/features/dispatch.md — new. Dispatch board, scheduling, tags,
travel-time optimization, magic-link SMS, SSE updates.
- docs/features/customer-portal.md — new. Plan A passwordless magic-link
at portal.gigafibre.ca, Stripe self-service, file inventory.
- docs/architecture/module-interactions.md — new. One-page call graph
with sequence diagrams for the hot paths.
- docs/README.md — expanded module index (§2) now lists every deployed
surface with URL + primary doc + primary code locations (was missing
dispatch, tickets, équipe, rapports, telephony, network, agent-flows,
OCR, every customer-portal page). New cross-module edge map in §4.
- docs/features/README.md + docs/architecture/README.md — cross-link
all new docs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
23 KiB
Dispatch
Real-time technician scheduling, work-order creation, and field publishing for the Ops PWA. Largest module in
apps/ops/— the single page where CSRs turn incoming service requests into scheduled work on a technician's calendar.
1. Goals & Principles
Dispatch is the live control surface for every field intervention. It replaces the legacy PHP dispatch-app (slated for retirement per ../architecture/overview.md) with a Vue 3 / Pinia timeline bound directly to ERPNext Dispatch Job and Dispatch Technician DocTypes.
Principles enforced in the code:
- Single source of truth is ERPNext. Every drag, resize, tag toggle issues a
PUT /api/resource/Dispatch Job/{name}throughsrc/api/dispatch.js. The Pinia store (src/stores/dispatch.js) only holds a mapped view; on reload it rehydrates from Frappe. - Optimistic UI + undo stack.
useUndosnapshots mutations so keyboardCmd+Zcan roll back a mistake before ERPNext ever replies. - Match skill, not cost. Per
feedback_dispatch_tags.mdconvention, tags carry a level 1-5 and auto-dispatch picks the lowest adequate match — experts are preserved for jobs that require them. - Push, don't poll. Timeline stays in sync via SSE topics
dispatchandnetworkfromtargo-hub— tech-absence SMS, outage alerts, OLT up/down events all land as toasts without a refresh. - Human and material resources share one grid. A
Dispatch Technicianrow is either a human (with a phone / user) or a material asset (Véhicule, Nacelle, OTDR, Fusionneuse…) — the same drag-drop logic applies. - Work is not visible to techs until it is published. The
publishedflag on a Dispatch Job gates both the mobile page (/t/{token}) and the iCal feed.
2. File Inventory
Front-end (Ops PWA)
| Path | Responsibility |
|---|---|
apps/ops/src/pages/DispatchPage.vue |
Top-level page, composes all composables, owns SSE connection, renders timeline / week / month switcher plus inline map panel. |
apps/ops/src/modules/dispatch/components/TimelineRow.vue |
One row per resource — renders shift, absence, travel, assist, ghost and job segments; emits drag / drop / resize events. |
apps/ops/src/modules/dispatch/components/WeekCalendar.vue |
7-day grid view with absence chips, ghost recurrences and day-load bars; handles planning mode shifts and on-call bands. |
apps/ops/src/modules/dispatch/components/MonthCalendar.vue |
Month overview with per-day tech-count badges; shows selected tech's availability when in planning mode. |
apps/ops/src/modules/dispatch/components/BottomPanel.vue |
Unassigned jobs tray with lasso selection, batch assign and auto-distribute buttons. |
apps/ops/src/modules/dispatch/components/RightPanel.vue |
Details side-panel for a selected job — edit, move, geofix, unassign, set end date, remove assistant, assign pending, update tags. |
apps/ops/src/modules/dispatch/components/JobEditModal.vue |
Inline edit of title, address, note, duration, priority and tags. |
apps/ops/src/modules/dispatch/components/WoCreateModal.vue |
"Nouveau work order" modal — runs rankTechs + refineWithDrivingTimes on the top 3 candidates. |
apps/ops/src/modules/dispatch/components/CreateOfferModal.vue |
Creates a job offer in broadcast / targeted / pool mode with a pricing preset. |
apps/ops/src/modules/dispatch/components/OfferPoolPanel.vue |
Live offer feed with status chips (open, pending, accepted, expired, cancelled). |
apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue |
"Publier & envoyer l'horaire par SMS" — publishes jobs, builds per-tech magic link + webcal iCal URL, sends via Twilio. |
apps/ops/src/modules/dispatch/components/SbModal.vue |
Generic overlay modal with header / body / footer slots. |
apps/ops/src/modules/dispatch/components/SbContextMenu.vue |
Positioned context-menu wrapper used for right-click menus on techs and jobs. |
apps/ops/src/modules/dispatch/components/MapPanel.vue |
Standalone map panel component — present but not imported by DispatchPage.vue, which uses an inline Mapbox block instead. |
apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue |
Client for /dispatch/suggest-slots (hub endpoint). Present but not imported by DispatchPage.vue — kept for future "Find me a time" entry point. |
apps/ops/src/stores/dispatch.js |
Pinia store — maps ERPNext rows to UI-friendly shape, rebuilds tech queues, triggers GPS polling. |
apps/ops/src/config/dispatch.js |
Row height constant, resource icon map (Véhicule, Nacelle, Fusionneuse…), re-exports HUB_SSE_URL. |
apps/ops/src/api/dispatch.js |
All Frappe REST calls — fetchTechnicians, fetchJobsFast, updateJob, publishJobs, createJob, createTech. |
apps/ops/src/composables/useScheduler.js |
Drives period navigation, busy lanes, ghost materialization. |
apps/ops/src/composables/useDragDrop.js |
Drag sources / drop targets for jobs, techs and assistants. |
apps/ops/src/composables/useAutoDispatch.js |
Client-side candidate ranking mirroring the hub algorithm. |
apps/ops/src/composables/useJobOffers.js |
Offer-pool state + PRICING_PRESETS. |
apps/ops/src/composables/useAbsenceResize.js |
Drag-to-resize absence bands. |
apps/ops/src/composables/useTechManagement.js |
Create / delete / reassign technicians. |
apps/ops/src/composables/useTagManagement.js |
Tag editor bindings for techs and jobs. |
apps/ops/src/composables/useContextMenus.js |
Tech and job right-click menus. |
apps/ops/src/composables/useSelection.js |
Lasso selection in the bottom panel. |
apps/ops/src/composables/useBottomPanel.js |
Collapsed / pinned state of the unassigned tray. |
apps/ops/src/composables/usePeriodNavigation.js |
3-period buffer and infinite horizontal scroll. |
apps/ops/src/composables/useResourceFilter.js |
Human / material / group filtering. |
apps/ops/src/composables/useMap.js |
Mapbox GL layer and marker lifecycle. |
apps/ops/src/composables/useUndo.js |
Per-mutation snapshots for Cmd+Z. |
apps/ops/src/composables/useAddressSearch.js |
Mapbox geocoder wrapper used by Wo and Job modals. |
apps/ops/src/components/dispatch/NlpInput.vue |
Natural-language "type-to-dispatch" shortcut. |
Back-end (targo-hub)
| Path | Responsibility |
|---|---|
services/targo-hub/server.js |
Routes /dispatch/* to dispatch.js, /dispatch/ical-token/:id and /dispatch/calendar/:id.ics to ical.js, /t/* to tech-mobile.js, /traccar/* to traccar.js, /magic-link/* to magic-link.js. |
services/targo-hub/lib/dispatch.js |
POST /dispatch/best-tech, POST /dispatch/suggest-slots, POST /dispatch/create-job. Implements rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots. |
services/targo-hub/lib/ical.js |
HMAC-SHA256 token signing, VCALENDAR builder with America/Toronto VTIMEZONE, status + priority mapping, RRULE passthrough. |
services/targo-hub/lib/traccar.js |
Bearer-auth proxy to the Traccar GPS server with a 60s device cache. |
services/targo-hub/lib/magic-link.js |
POST /magic-link/generate, POST /magic-link/verify, POST /magic-link/refresh — JWT tokens, default 72h TTL, job-level and all-jobs-for-tech variants. |
services/targo-hub/lib/tech-mobile.js |
Server-rendered mobile page at /t/{token} with status update, scan, vision, equipment and catalog endpoints. |
services/targo-hub/lib/tech-absence-sms.js |
Inbound Twilio SMS → Gemini Flash NLU (regex fallback) → setTechAbsence / clearTechAbsence → SSE tech-absence broadcast. |
services/targo-hub/lib/sse.js |
Topic fan-out used by the dispatch and network channels. |
ERPNext (Frappe custom DocTypes)
| Path | Responsibility |
|---|---|
erpnext/setup_fsm_doctypes.py |
Creates / patches custom fields on Dispatch Job and Dispatch Technician. |
3. Data Model
Dispatch Job
Core Frappe fields plus these custom fields (from erpnext/setup_fsm_doctypes.py):
| Field | Type | Purpose |
|---|---|---|
customer |
Link → Customer | Billing-side link. |
service_location |
Link → Service Location | Physical address + geocoded coordinates. |
job_type |
Select | Installation / Réparation / Maintenance / Retrait / Dépannage / Autre. |
source_issue |
Link → Issue | Origin ticket, when the job came from Support. |
depends_on |
Link → Dispatch Job | Blocks scheduling until the dependency is done. |
parent_job |
Link → Dispatch Job | Multi-step workflow (with step_order). |
on_open_webhook, on_close_webhook |
Data | Flow-editor callbacks — see flow-editor.md. |
equipment_items, materials_used, checklist, photos |
Table | Child tables fed by the mobile tech page. |
actual_start, actual_end, travel_time_min |
Datetime / Int | Field-recorded metrics. |
completion_notes, customer_signature |
Text / Attach | End-of-job capture. |
published |
Check | Gates visibility on the mobile page and iCal feed. |
is_recurring, recurrence_rule, recurrence_end, pause_periods, template_id |
Mixed | RRULE-based repeating jobs (materialized on demand). |
continuous |
Check | Emergency jobs that may span weekends / off-days. |
assistants (child table) |
Table | tech_id, tech_name, duration_h, note, pinned. |
tags (child table) |
Table | tag, level, required. |
Dispatch Technician
| Field | Type | Purpose |
|---|---|---|
resource_type |
Select | human or material. |
resource_category |
Select | Véhicule / Outil / Salle / Équipement / Nacelle / Grue / Fusionneuse / OTDR. |
weekly_schedule |
Text / JSON | Parsed by parseWeeklySchedule. |
extra_shifts |
JSON (hidden) | Per-date overrides. |
absence_reason, absence_from, absence_until, absence_start_time, absence_end_time |
Mixed | Current absence band. |
traccar_device_id |
Data | Links to a Traccar GPS device. |
tags (child table) |
Table | tag, level. |
Full field definitions live in ../architecture/data-model.md.
4. UI Surfaces
All French strings below are pulled directly from DispatchPage.vue.
Top bar
"Ressources" (resource filter), "Filtres" (tag filter), "Aujourd'hui" (jump-to-today), "Jour / Semaine / Mois" view toggle, "Planning" mode for availability editing, "Carte" for the inline map, "Publier" for the publish modal, "+ WO" for the work-order creator, "ERP" status pill.
Timeline view (TimelineRow.vue)
One row per resource. Each row renders stacked segments:
| Segment type | Source | Notes |
|---|---|---|
shift |
weeklySchedule + extraShifts |
Active working window; drop targets enabled. |
absence |
absence_from..absence_until or weekly "off" days |
Resizable via useAbsenceResize. |
travel |
Computed between consecutive jobs | Mapbox Directions API; non-draggable. |
assist |
assistants[] on another tech's job |
Pinned assists cannot be dragged away. |
ghost |
Unmaterialized recurrence | Click → materialize concrete Dispatch Job. |
job |
Assigned, scheduled Dispatch Job | Primary drag source. |
Drag sources: tech avatar (to split shifts), job blocks (to move / reassign), pinned assist blocks (to nudge helper). Emits select-tech, ctx-tech, job-dragstart, timeline-drop, block-move, block-resize, absence-resize, ghost-click, ghost-materialize.
Week view (WeekCalendar.vue)
Seven-day grid per resource. Distinguishes isExplicitAbsent (a typed absence record) from isScheduleOff (non-working weekday). In planning mode it draws availability bands and on-call shift overlays on top.
Month view (MonthCalendar.vue)
Month grid with per-day badges showing how many techs are available. In planning mode a sidebar surfaces the selected tech's weekly schedule and extra shifts.
Bottom panel (BottomPanel.vue)
Unassigned jobs tray. Supports:
- Lasso selection (click-drag to select multiple)
- Batch assign to a tech
- Auto-distribute across available techs using
useAutoDispatch - Collapse / pin state via
useBottomPanel
Right panel (RightPanel.vue)
Details + pending-request slide-in. Emits edit, move, geofix, unassign, set-end-date, remove-assistant, assign-pending, update-tags.
Inline map
DispatchPage renders its own Mapbox panel using useMap and MAPBOX_TOKEN from config/erpnext.js. MapPanel.vue exists in the module but is not currently imported — kept for a possible future extraction.
Offer pool (OfferPoolPanel.vue, CreateOfferModal.vue)
Three offer modes surface as icon toggles in CreateOfferModal:
| Mode | Behaviour |
|---|---|
broadcast |
Blast to every matching tech; first accepter wins. |
targeted |
Single named recipient. |
pool |
A named candidate list; first to accept wins. |
Pricing is composed from PRICING_PRESETS (in useJobOffers) as displacement$ + hourlyRate$/h × duration. Offer status chips: open, pending, accepted, expired, cancelled.
Context menu (SbContextMenu.vue)
Right-click on a tech row or job block opens a context menu wired through useContextMenus — quick reassign, cancel, open in ERP, copy magic link, copy iCal URL.
Drag-drop rules (enforced in useDragDrop)
- Drop on an active shift / within absence band = allowed (toggle absence).
- Drop on a non-working weekday = blocked unless
continuous=1. - Drop on a tech whose tags don't meet
tags.requiredlevels = warning toast but still allowed (CSR override). Escapecancels any in-flight drag.Deletewhile a job is selected opens the unassign confirmation.Cmd+Zwalks back theuseUndosnapshot stack.
5. Key Flows
5.1 Create a work order
- CSR clicks
+ WO→WoCreateModal.vueopens. - Address input auto-geocodes via
useAddressSearch(Mapbox geocoder). - On submit,
WoCreateModalcallsfindBestTech()which locally runs the ranking, then refines the top 3 with live driving times. createJob(insrc/api/dispatch.js) POSTs to ERPNext.- The store prepends the new job; SSE fan-out notifies other open Ops tabs.
5.2 Suggest the best tech (server side)
POST /dispatch/best-tech on targo-hub (lib/dispatch.js):
rankTechscomputes a score per candidate using SCORE_WEIGHTS —proximityMultiplier=4,proximityMax=100km,loadMultiplier=30,overloadPenalty=500,gpsFreshnessBonus=20.- Proximity uses a Euclidean km approximation at Montreal latitude (fast enough for ranking).
getTechsWithLoadtallies today's scheduled hours; anything above the configured cap triggers the overload penalty.enrichWithGpsadds a freshness bonus when a recent Traccar fix exists.
5.3 Suggest time slots
POST /dispatch/suggest-slots (lib/dispatch.js) returns up to N best windows across all techs:
- 7-day horizon, capped at 2 slots per tech to diversify suggestions.
- Default shift 08:00-17:00 (overridable per tech).
- 15-minute travel buffer inserted before/after existing jobs.
SuggestSlotsDialog.vueis the front-end client — present in the repo but not yet wired intoDispatchPage.vue; the current path is tech selection viaWoCreateModalor manual drag.
5.4 Offer pool
- CSR opens
CreateOfferModal, picks mode + pricing preset. - Offer is persisted through
useJobOffers. OfferPoolPanelsubscribes to offer events and updates chip status.- Accepting techs fire a state transition that converts the offer into an assigned Dispatch Job.
5.5 Publish & SMS schedule
PublishScheduleModal.vue — title "Publier & envoyer l'horaire par SMS":
- Operator selects a tech + date range.
publishJobs(jobNames)fromsrc/api/dispatch.jsPUTspublished: 1on each job in parallel.- For each tech, the modal fetches:
- A magic link via
POST /magic-link/generate(targo-hub, default 72h TTL) → produces the/t/{token}URL that opens the mobile tech page. - An iCal token via
GET /dispatch/ical-token/{techId}→ builds awebcal://URL pointing at/dispatch/calendar/{techId}.ics?token=....
- A magic link via
- Twilio sends two SMS:
- "Mes tâches: {link}" — the magic link.
- "Ajouter à mon calendrier: {webcal url}" — the iCal subscription URL.
sendTestSmsis invoked withreference_doctype = Dispatch Technicianso every send is logged against the tech in ERPNext.
5.6 Absence via SMS (inbound)
services/targo-hub/lib/tech-absence-sms.js:
- Twilio inbound webhook hits
handleAbsenceSms. lookupTechByPhonematches the last 10 digits of the sender against Dispatch Technicians.- Gemini 2.5 Flash (OpenAI-compatible endpoint) parses intent + dates; a regex fallback catches "absent / malade / sick / formation / vacances" with simple date ranges if the LLM is down.
setTechAbsenceorclearTechAbsencePUTs to Dispatch Technician and broadcasts SSEtech-absenceon thedispatchtopic.- Ops timelines update in-place; the operator sees a toast. A French confirmation SMS is returned to the tech.
5.7 Recurring jobs (ghosts → materialized)
- A template job carries
is_recurring=1and an RFC 5545recurrence_rule(e.g.FREQ=WEEKLY;BYDAY=SA,SU). WeekCalendarandTimelineRowrender upcoming occurrences as ghost segments computed client-side.- Clicking a ghost fires
ghost-materialize, creating a concrete Dispatch Job withtemplate_idset — subsequent edits diverge from the template. pause_periodscarves out vacation windows without deleting the template.
6. External Integrations
Traccar (GPS)
services/targo-hub/lib/traccar.js proxies /traccar/devices, /traccar/positions and a generic GET fallback with Traccar bearer-token auth preferred over Basic. Device metadata is cached for 60 seconds. The store (useGpsTracking) polls positions and feeds live gpsCoords, gpsSpeed, gpsOnline into each tech row — these in turn power the freshness bonus in the server-side ranker. Traccar limitation noted in ../architecture/overview.md #6.4: only one deviceId per request — poll in parallel.
Twilio
- Outbound —
PublishScheduleModalcallssendTestSmsto deliver the magic link and webcal URL per tech. - Inbound —
tech-absence-sms.jsowns Twilio's incoming SMS webhook and drives the absence flow above. - Number
+14382313838perreference_twilio.md.
iCal / webcal
services/targo-hub/lib/ical.js:
- Token is HMAC-SHA256 over
{techId}withICAL_SECRET, truncated to 16 hex chars. - Fetches jobs from past 7 days to future 60 days for the tech.
- Emits RFC 5545 with a full
America/TorontoVTIMEZONE (DST rules included). - Status:
open → TENTATIVE,cancelled/completed → CANCELLED. - Priority:
urgent → 1,medium → 5, everything else →9. - Honors
recurrence_rule— passes the RRULE through untouched.
Subscription URL handed to techs is webcal://msg.gigafibre.ca/dispatch/calendar/{techId}.ics?token=... — Apple Calendar and Google Calendar both handle this.
Mobile tech page (/t/{token})
services/targo-hub/lib/tech-mobile.js is a lightweight server-rendered page — not the Ops PWA. It authenticates the tech with a JWT magic link (verifyJwt), lists their published jobs for the day, and offers POST endpoints:
| Route | Purpose |
|---|---|
GET /t/{token} |
Day view for the tech. |
POST /t/{token}/status |
Flip status (In Progress, Completed). |
POST /t/{token}/scan |
Record a barcode scan. |
POST /t/{token}/vision |
Send a photo to /vision/barcodes for OCR. |
POST /t/{token}/equip |
Install a Service Equipment row. |
POST /t/{token}/equip-remove |
Decommission equipment. |
GET /t/{token}/catalog |
Fetch the equipment catalog. |
GET /t/{token}/equip-list |
Fetch equipment already on site. |
The Ops PWA never opens this page — it lives alongside Dispatch to replace the retiring apps/field app. See ../architecture/overview.md §1.
7. Cross-module References
- ../architecture/overview.md — ecosystem map, retirement plan for the legacy dispatch-app and field app.
- ../architecture/data-model.md — canonical Dispatch Job / Dispatch Technician field list.
- flow-editor.md — wires
on_open_webhook/on_close_webhookon Dispatch Job into workflow runs. - vision-ocr.md — barcode / equipment recognition consumed by the tech mobile page during a dispatch.
- cpe-management.md — "Diagnostiquer" deep-dives started from a customer card flow into the same tech assignment surface.
- billing-payments.md — Sales Orders that trigger installation Dispatch Jobs (
order_source,sales_orderfields on the job).
8. Failure Modes
| Symptom | Likely cause | Where to look |
|---|---|---|
| Timeline loads but all rows are empty | ERPNext token revoked or Administrator "Generate Keys" clicked | src/api/auth.js + note in ../architecture/overview.md §6.3 |
| Drops appear to work then revert on refresh | ERPNext exc swallowed — check console for [API] PUT Dispatch Job/... failed |
apps/ops/src/api/dispatch.js |
| iCal subscription returns 401 | Wrong token or ICAL_SECRET changed — ask operator to re-copy the webcal URL |
services/targo-hub/lib/ical.js |
| Tech never receives magic link SMS | Twilio rejected the number or published=0 on the selected jobs |
PublishScheduleModal.vue + Twilio console |
| Absence SMS ignored | Phone number not matched in Dispatch Technician (last-10-digits LIKE) | services/targo-hub/lib/tech-absence-sms.js → lookupTechByPhone |
| GPS dot stale / missing | Traccar token expired, cache 60s, or no traccar_device_id on the tech |
services/targo-hub/lib/traccar.js |
| Suggestion always picks same tech | Overload penalty not firing — check getTechsWithLoad result for load cap |
services/targo-hub/lib/dispatch.js |
| SSE toasts stop arriving | ForwardAuth dropped the session; reload to re-auth with Authentik | apps/ops/src/pages/DispatchPage.vue SSE setup |
| Ghost occurrences missing | RRULE parse failed client-side — malformed recurrence_rule |
apps/ops/src/stores/dispatch.js _mapJob |
| Tech mobile page shows "not found" | JWT expired (default 72h) — operator must re-publish to regenerate | services/targo-hub/lib/magic-link.js |
9. Retirement Note
The legacy dispatch-app (PHP) and apps/field (mobile technician SPA) are slated for retirement in April-May 2026, per ../architecture/overview.md §1. The replacement pairing is:
- Ops PWA Dispatch module — this document — for all scheduling, ranking, publishing.
- Lightweight tech mobile page at
/t/{token}(see §6) for the field-side experience.
Do not add new features to dispatch-app or apps/field. Any feature request should land in apps/ops/src/modules/dispatch/ or the tech-mobile handler in services/targo-hub/lib/tech-mobile.js.
Back to docs/README.md · roadmap.md