# 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](../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}` through `src/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.** `useUndo` snapshots mutations so keyboard `Cmd+Z` can roll back a mistake before ERPNext ever replies.
- **Match skill, not cost.** Per `feedback_dispatch_tags.md` convention, 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 `dispatch` and `network` from `targo-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 Technician` row 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 `published` flag 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](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](../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.required` levels = warning toast but still allowed (CSR override).
- `Escape` cancels any in-flight drag.
- `Delete` while a job is selected opens the unassign confirmation.
- `Cmd+Z` walks back the `useUndo` snapshot stack.

## 5. Key Flows

### 5.1 Create a work order

1. CSR clicks `+ WO` → `WoCreateModal.vue` opens.
2. Address input auto-geocodes via `useAddressSearch` (Mapbox geocoder).
3. On submit, `WoCreateModal` calls `findBestTech()` which locally runs the ranking, then refines the top 3 with live driving times.
4. `createJob` (in `src/api/dispatch.js`) POSTs to ERPNext.
5. 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`):

- `rankTechs` computes a score per candidate using SCORE_WEIGHTS — `proximityMultiplier=4`, `proximityMax=100` km, `loadMultiplier=30`, `overloadPenalty=500`, `gpsFreshnessBonus=20`.
- Proximity uses a Euclidean km approximation at Montreal latitude (fast enough for ranking).
- `getTechsWithLoad` tallies today's scheduled hours; anything above the configured cap triggers the overload penalty.
- `enrichWithGps` adds 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.vue` is the front-end client — present in the repo but not yet wired into `DispatchPage.vue`; the current path is tech selection via `WoCreateModal` or manual drag.

### 5.4 Offer pool

1. CSR opens `CreateOfferModal`, picks mode + pricing preset.
2. Offer is persisted through `useJobOffers`.
3. `OfferPoolPanel` subscribes to offer events and updates chip status.
4. 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":

1. Operator selects a tech + date range.
2. `publishJobs(jobNames)` from `src/api/dispatch.js` PUTs `published: 1` on each job in parallel.
3. 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 a `webcal://` URL pointing at `/dispatch/calendar/{techId}.ics?token=...`.
4. Twilio sends two SMS:
   - "Mes tâches: {link}" — the magic link.
   - "Ajouter à mon calendrier: {webcal url}" — the iCal subscription URL.
5. `sendTestSms` is invoked with `reference_doctype = Dispatch Technician` so every send is logged against the tech in ERPNext.

### 5.6 Absence via SMS (inbound)

`services/targo-hub/lib/tech-absence-sms.js`:

1. Twilio inbound webhook hits `handleAbsenceSms`.
2. `lookupTechByPhone` matches the last 10 digits of the sender against Dispatch Technicians.
3. 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.
4. `setTechAbsence` or `clearTechAbsence` PUTs to Dispatch Technician and broadcasts SSE `tech-absence` on the `dispatch` topic.
5. 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=1` and an RFC 5545 `recurrence_rule` (e.g. `FREQ=WEEKLY;BYDAY=SA,SU`).
- `WeekCalendar` and `TimelineRow` render upcoming occurrences as ghost segments computed client-side.
- Clicking a ghost fires `ghost-materialize`, creating a concrete Dispatch Job with `template_id` set — subsequent edits diverge from the template.
- `pause_periods` carves 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](../architecture/overview.md) #6.4: only one `deviceId` per request — poll in parallel.

### Twilio

- **Outbound** — `PublishScheduleModal` calls `sendTestSms` to deliver the magic link and webcal URL per tech.
- **Inbound** — `tech-absence-sms.js` owns Twilio's incoming SMS webhook and drives the absence flow above.
- Number `+14382313838` per `reference_twilio.md`.

### iCal / webcal

`services/targo-hub/lib/ical.js`:

- Token is HMAC-SHA256 over `{techId}` with `ICAL_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/Toronto` VTIMEZONE (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](../architecture/overview.md) §1.

## 7. Cross-module References

- [../architecture/overview.md](../architecture/overview.md) — ecosystem map, retirement plan for the legacy dispatch-app and field app.
- [../architecture/data-model.md](../architecture/data-model.md) — canonical Dispatch Job / Dispatch Technician field list.
- [flow-editor.md](flow-editor.md) — wires `on_open_webhook` / `on_close_webhook` on Dispatch Job into workflow runs.
- [vision-ocr.md](vision-ocr.md) — barcode / equipment recognition consumed by the tech mobile page during a dispatch.
- [cpe-management.md](cpe-management.md) — "Diagnostiquer" deep-dives started from a customer card flow into the same tech assignment surface.
- [billing-payments.md](billing-payments.md) — Sales Orders that trigger installation Dispatch Jobs (`order_source`, `sales_order` fields 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](../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](../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](../README.md) · [roadmap.md](../roadmap.md)
