improve(ops): accueil — détecte les ARRIVÉES EN RETARD sur les interventions du jour

Enrichit la carte « Interventions aujourd'hui » (au-delà du simple découpage par statut) : croise l'heure de début
prévue avec l'état géofence live → pastille ROUGE « N en retard » quand le début est passé de > 20 min mais le tech
n'est PAS montré sur place (ni parti). Signal proactif de retard/no-show pour le répartiteur. Réutilise /roster/geofence-states.
Propre (composant seul, aucun changement hub). Vérifié en dev : rendu 3 cartes, aucune erreur console (0 job aujourd'hui
en dev → pastille masquée, correct). Build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 16:34:28 -04:00
parent 0d80c323f0
commit f8af4235fb

View File

@ -50,6 +50,10 @@ const jobBreakdown = computed(() => {
return STATUS.map(s => ({ ...s, n: by[s.key] || 0 })).filter(s => s.n > 0) return STATUS.map(s => ({ ...s, n: by[s.key] || 0 })).filter(s => s.n > 0)
}) })
// Heure courante (Québec) en minutes depuis minuit pour détecter les arrivées EN RETARD sur les jobs du jour.
function nowEtMinutes () {
try { const p = new Date().toLocaleTimeString('en-GB', { timeZone: TZ, hour12: false }); const a = p.split(':'); return (Number(a[0]) || 0) * 60 + (Number(a[1]) || 0) } catch (e) { return 0 }
}
async function hubGet (path) { async function hubGet (path) {
try { const r = await fetch(HUB_URL + path, { headers: { 'Content-Type': 'application/json' } }); return r.ok ? await r.json() : null } catch { return null } try { const r = await fetch(HUB_URL + path, { headers: { 'Content-Type': 'application/json' } }); return r.ok ? await r.json() : null } catch { return null }
} }
@ -65,16 +69,24 @@ async function load () {
} }
if (showJobs.value) { if (showJobs.value) {
tasks.push((async () => { tasks.push((async () => {
const js = await listDocs('Dispatch Job', { filters: { scheduled_date: today }, fields: ['name', 'status'], limit: 300 }).catch(() => []) const js = await listDocs('Dispatch Job', { filters: { scheduled_date: today }, fields: ['name', 'status', 'start_time'], limit: 300 }).catch(() => [])
const by = {}; for (const j of (js || [])) by[j.status] = (by[j.status] || 0) + 1 const by = {}; for (const j of (js || [])) by[j.status] = (by[j.status] || 0) + 1
let enRoute = 0, onSite = 0 let enRoute = 0, onSite = 0, atRisk = 0, st = {}
const names = (js || []).map(j => j.name).slice(0, 120) const names = (js || []).map(j => j.name).slice(0, 120)
if (names.length) { if (names.length) {
const g = await hubGet('/roster/geofence-states?jobs=' + encodeURIComponent(names.join(','))) const g = await hubGet('/roster/geofence-states?jobs=' + encodeURIComponent(names.join(',')))
const st = (g && g.states) || {} st = (g && g.states) || {}
for (const k in st) { if (st[k] === 'en_route') enRoute++; else if (st[k] === 'on_site') onSite++ } for (const k in st) { if (st[k] === 'en_route') enRoute++; else if (st[k] === 'on_site') onSite++ }
} }
jobs.value = { total: (js || []).length, by, enRoute, onSite } // EN RETARD : début prévu passé de > 20 min mais le géofence ne montre PAS le tech arrivé (ni parti) retard d'arrivée.
const nowMin = nowEtMinutes()
for (const j of (js || [])) {
if (j.status !== 'assigned' || !j.start_time) continue
const s = String(j.start_time); const sm = (Number(s.slice(0, 2)) || 0) * 60 + (Number(s.slice(3, 5)) || 0)
const state = st[j.name]
if (state !== 'on_site' && state !== 'departed' && (nowMin - sm) > 20) atRisk++
}
jobs.value = { total: (js || []).length, by, enRoute, onSite, atRisk }
})()) })())
} }
try { await Promise.all(tasks) } catch (e) { /* dégradé */ } try { await Promise.all(tasks) } catch (e) { /* dégradé */ }
@ -127,7 +139,8 @@ function allocate () { window.dispatchEvent(new CustomEvent('ops:assistant', { d
<div class="ma-row"> <div class="ma-row">
<div class="ma-big">{{ dash(jobs && jobs.total) }}</div> <div class="ma-big">{{ dash(jobs && jobs.total) }}</div>
<div class="ma-livewrap"> <div class="ma-livewrap">
<div v-if="jobs && (jobs.enRoute || jobs.onSite)" class="ma-live"> <div v-if="jobs && (jobs.enRoute || jobs.onSite || jobs.atRisk)" class="ma-live">
<span v-if="jobs.atRisk" class="ma-chip live" style="color:#d2483d;background:#d2483d1a"><span class="pulse" style="background:#d2483d"></span><b>{{ jobs.atRisk }}</b> en retard</span>
<span v-if="jobs.enRoute" class="ma-chip live" style="color:#3b6fb0;background:#3b6fb01a"><span class="pulse" style="background:#3b6fb0"></span><b>{{ jobs.enRoute }}</b> en route</span> <span v-if="jobs.enRoute" class="ma-chip live" style="color:#3b6fb0;background:#3b6fb01a"><span class="pulse" style="background:#3b6fb0"></span><b>{{ jobs.enRoute }}</b> en route</span>
<span v-if="jobs.onSite" class="ma-chip live" style="color:#2e9e5b;background:#2e9e5b1a"><span class="pulse" style="background:#2e9e5b"></span><b>{{ jobs.onSite }}</b> sur place</span> <span v-if="jobs.onSite" class="ma-chip live" style="color:#2e9e5b;background:#2e9e5b1a"><span class="pulse" style="background:#2e9e5b"></span><b>{{ jobs.onSite }}</b> sur place</span>
</div> </div>