gigafibre-fsm/docs/features/dispatch.md
louispaulb 30a867a326 fix(tech): restore Gemini-native scanner + port equipment UX into ops
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>
2026-04-22 15:56:38 -04:00

333 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)