fix(ops/dispatch): surface customer + service-location links from a job + fix bad coords

Two related issues, one PR:

1. **Bad coords** on customer C-LPB4's "Wifi buggy" job (DJ-MNP8WIKT).
   Address on file is `691 rue des Hirondelles, Saint-Michel J0L2J0`,
   but the saved lat/lng (-73.677086, 45.159206) reverse-geocodes to
   `2336 rue René-Vinet, Sainte-Clotilde J0L1W0` — ~9 km away. The
   delta matches the Gigafibre HQ default fallback (-73.6756, 45.1599)
   pretty closely, suggesting the geocoder either failed silently at
   Service Location creation time or got pinned to the HQ centroid.

   Fixed live in DB (UPDATE on tabService Location LOC-0000000004 +
   tabDispatch Job DJ-MNP8WIKT to lng=-73.5792377, lat=45.2408452,
   verified via Nominatim against the typed address). The job pin
   should now show on the correct house.

2. **No way to jump from a job to the client** — the dispatcher had
   to memorize/type the customer ID. Now both the RightPanel and the
   job context-menu surface clickable shortcuts:
     • Client → `#/clients/<id>` (opens ClientDetailPage in-app)
     • Lieu  → `/desk/Service Location/<id>` (opens ERPNext in a new
       tab; the ops SPA doesn't have a dedicated SL detail page)

   Required wiring `customer` + `serviceLocation` into the job map in
   `stores/dispatch.js` — the API (`fetchJobsFast` uses `["*"]`) was
   already returning the fields, the store just wasn't surfacing them.

Note on the deeper bug: the SL lat/lng is the source of truth and the
job currently *copies* it at creation time (rather than reading from
the SL link dynamically). If a Service Location's coords are corrected
after a job exists, the job retains stale coords. A follow-up could
either (a) re-read on render, or (b) trigger a backfill when SL coords
change. Out of scope for this fix — for now, the dispatcher who fixes
an SL must also update any open jobs at that location.
This commit is contained in:
louispaulb 2026-05-08 10:29:59 -04:00
parent 2ec5e49a06
commit f4ae023302
4 changed files with 43 additions and 0 deletions

View File

@ -36,6 +36,28 @@ const onDeleteTag = inject('onDeleteTag')
<div class="sb-rp-body"> <div class="sb-rp-body">
<div class="sb-rp-color-bar" :style="'background:'+jobColor(panel.data?.job||{})"></div> <div class="sb-rp-color-bar" :style="'background:'+jobColor(panel.data?.job||{})"></div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Titre</span><strong>{{ panel.data?.job?.subject }}</strong></div> <div class="sb-rp-field"><span class="sb-rp-lbl">Titre</span><strong>{{ panel.data?.job?.subject }}</strong></div>
<!-- Client + Lieu de service: clickable shortcuts. Customer opens
the ClientDetailPage in the same SPA (Vue Router hash route);
Service Location opens in ERPNext desk in a new tab since the
ops SPA doesn't have a dedicated SL detail page. The address
below is the free-text on the job; the persisted lat/lng
(used by the Mapbox marker) lives on the linked Service
Location discrepancy = bad geocode at SL creation time. -->
<div v-if="panel.data?.job?.customer" class="sb-rp-field">
<span class="sb-rp-lbl">Client</span>
<a class="sb-rp-link" :href="'#/clients/' + panel.data.job.customer">
{{ panel.data.job.customer }}
<span class="sb-rp-link-icon"></span>
</a>
</div>
<div v-if="panel.data?.job?.serviceLocation" class="sb-rp-field">
<span class="sb-rp-lbl">Lieu</span>
<a class="sb-rp-link" target="_blank" rel="noopener"
:href="'/desk/Service Location/' + panel.data.job.serviceLocation">
<code>{{ panel.data.job.serviceLocation }}</code>
<span class="sb-rp-link-icon"></span>
</a>
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.job?.address || '—' }}</div> <div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.job?.address || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Durée</span>{{ fmtDur(panel.data?.job?.duration) }}</div> <div class="sb-rp-field"><span class="sb-rp-lbl">Durée</span>{{ fmtDur(panel.data?.job?.duration) }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Priorité</span> <div class="sb-rp-field"><span class="sb-rp-lbl">Priorité</span>

View File

@ -1672,6 +1672,15 @@ onUnmounted(() => {
<button class="sb-ctx-item" @click="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button> <button class="sb-ctx-item" @click="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button>
<button class="sb-ctx-item" @click="offerUnassignedJob(ctxMenu.job); closeCtxMenu()">📡 Offrir aux ressources</button> <button class="sb-ctx-item" @click="offerUnassignedJob(ctxMenu.job); closeCtxMenu()">📡 Offrir aux ressources</button>
<div class="sb-ctx-sep"></div> <div class="sb-ctx-sep"></div>
<a v-if="ctxMenu?.job?.customer" class="sb-ctx-item sb-ctx-link"
:href="'#/clients/' + ctxMenu.job.customer" @click="closeCtxMenu()">
👤 Voir la fiche client ({{ ctxMenu.job.customer }})
</a>
<a v-if="ctxMenu?.job?.serviceLocation" class="sb-ctx-item sb-ctx-link" target="_blank" rel="noopener"
:href="'/desk/Service Location/' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()">
🏠 Lieu de service ({{ ctxMenu.job.serviceLocation }})
</a>
<div class="sb-ctx-sep"></div>
<button class="sb-ctx-item sb-ctx-warn" @click="ctxUnschedule()"> Désaffecter</button> <button class="sb-ctx-item sb-ctx-warn" @click="ctxUnschedule()"> Désaffecter</button>
</SbContextMenu> </SbContextMenu>

View File

@ -629,6 +629,10 @@
.sb-rp-urgent-tag { background:rgba(239,68,68,0.15); color:#ef4444; font-size:0.7rem; font-weight:700; padding:0.25rem 0.5rem; border-radius:6px; display:inline-block; margin-bottom:0.5rem; } .sb-rp-urgent-tag { background:rgba(239,68,68,0.15); color:#ef4444; font-size:0.7rem; font-weight:700; padding:0.25rem 0.5rem; border-radius:6px; display:inline-block; margin-bottom:0.5rem; }
.sb-rp-field { margin-bottom:0.45rem; color:var(--sb-text); } .sb-rp-field { margin-bottom:0.45rem; color:var(--sb-text); }
.sb-rp-lbl { display:block; font-size:0.58rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:0.1rem; } .sb-rp-lbl { display:block; font-size:0.58rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:0.1rem; }
.sb-rp-link { color:#6366f1; text-decoration:none; font-size:0.78rem; font-weight:600; display:inline-flex; align-items:center; gap:4px; }
.sb-rp-link:hover { text-decoration:underline; }
.sb-rp-link-icon { font-size:0.65rem; opacity:0.7; }
.sb-rp-link code { background:rgba(99,102,241,0.08); padding:1px 5px; border-radius:3px; font-size:0.7rem; }
.sb-rp-actions { padding:0.65rem 0.75rem; border-top:1px solid rgba(0,0,0,0.04); display:flex; flex-direction:column; gap:0.35rem; flex-shrink:0; } .sb-rp-actions { padding:0.65rem 0.75rem; border-top:1px solid rgba(0,0,0,0.04); display:flex; flex-direction:column; gap:0.35rem; flex-shrink:0; }
.sb-rp-primary { background:#6366f1; border:none; border-radius:7px; color:var(--sb-text); font-size:0.72rem; font-weight:700; padding:0.4rem 0.75rem; cursor:pointer; } .sb-rp-primary { background:#6366f1; border:none; border-radius:7px; color:var(--sb-text); font-size:0.72rem; font-weight:700; padding:0.4rem 0.75rem; cursor:pointer; }
.sb-rp-primary:hover { filter:brightness(1.12); } .sb-rp-primary:hover { filter:brightness(1.12); }

View File

@ -30,6 +30,14 @@ export const useDispatchStore = defineStore('dispatch', () => {
subject: j.subject || 'Job sans titre', subject: j.subject || 'Job sans titre',
address: j.address || 'Adresse inconnue', address: j.address || 'Adresse inconnue',
coords: [j.longitude || 0, j.latitude || 0], coords: [j.longitude || 0, j.latitude || 0],
// Persisted ERPNext links — surfaced as clickable shortcuts in
// the RightPanel so the dispatcher can jump to the client's
// full record (or the Service Location in ERPNext) without
// leaving the dispatch board. The Mapbox marker still uses
// the cached coords above; for source-of-truth verification
// (geocode mismatch) the rep clicks through to /clients/:id.
customer: j.customer || null,
serviceLocation: j.service_location || null,
priority: j.priority || 'low', priority: j.priority || 'low',
duration: j.duration_h || 1, duration: j.duration_h || 1,
status: j.status || 'open', status: j.status || 'open',