feat(dispatch): capacité AM/PM, durées additives, capture terrain (/field), sync techs
Hub (lib/roster.js, vision.js, legacy-dispatch-sync.js, server.js) + Ops + pont legacy. - Capacité par jour AM/PM (Soir = réserve garde/urgence, jamais offerte) calculée client-side sur les techs visibles -> suit le filtre de compétence. - Modèle de durée ADDITIF (caractéristiques, tableur inline) + auto-détection DÉTERMINISTE par mots-clés (sans IA permanente) ; est_min branché sur capacité + pool. - Capture terrain passive : endpoints publics /field (job/tech/checkpoint/ts/photo/ device/vision), tokens HMAC signés sans PII ; dérive actual_start/end. UI hébergée public/field-app.html (liste/carte Mapbox/Street View/photo/scan MLKit->Gemini). - Chrono job (start/finish), repositionnement carte (set-location), vue satellite, Street View clic-droit, année devant les dates dues groupées. - Sync techniciens : rapport de réconciliation 3 systèmes (staff legacy / Dispatch Technician / groupe Authentik), application MANUELLE, zéro écriture Authentik (+11 fiches). - vision.js : extractEquipment() réutilisable (marque/modèle/série/MAC/codes-barres). - Pont legacy (ops_reassign.php) : désassignation reflétée, fermeture ticket, retour au pool ; notification courriel à l'assignation. Déployé sur le hub ; ce commit aligne le repo sur l'état en production. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ebad7066d6
commit
98458861c3
|
|
@ -32,6 +32,8 @@ export const listRequirements = (start, days = 7) => jget(`/roster/requirements?
|
|||
export const createRequirement = (r) => jpost('/roster/requirements', r)
|
||||
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
|
||||
export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`)
|
||||
// Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) − jobs placés, + charge due
|
||||
export const getCapacity = (start, days = 7) => jget(`/roster/capacity?start=${start}&days=${days}`)
|
||||
export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`)
|
||||
export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`)
|
||||
export const getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`)
|
||||
|
|
@ -93,7 +95,7 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu
|
|||
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
|
||||
// Write-back legacy : aperçu (0 écriture) puis application (réassigne ticket.assign_to au tech dans osTicket)
|
||||
export const pushLegacyPreview = () => jget('/dispatch/legacy-sync/push-assignments')
|
||||
export const pushLegacyApply = () => jpost('/dispatch/legacy-sync/push-assignments', {})
|
||||
export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {})
|
||||
// Fil complet d'un ticket legacy (osTicket) : messages + réponses, pour l'expand au clic
|
||||
export const ticketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
|
||||
// Fermer un ticket dans le legacy (status=closed + date_closed + closed_by=acteur + réouverture enfants)
|
||||
|
|
@ -106,7 +108,22 @@ export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job,
|
|||
export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
|
||||
// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }]
|
||||
export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates })
|
||||
// Retirer un job d'un tech (retour au pool non assigné)
|
||||
// Retirer un job d'un tech (retour au pool non assigné) — ERPNext SEULEMENT (redistributions auto)
|
||||
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
|
||||
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte
|
||||
export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address })
|
||||
// Réconciliation des techniciens (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) : rapport + apply manuel
|
||||
// Lien app PWA du tech (à copier / SMS) + son téléphone
|
||||
export const techAppLink = (tech) => jget('/roster/tech-link?tech=' + encodeURIComponent(tech))
|
||||
// Table additive « durées par caractéristique » (éditable inline) — seed + apprentissage futur (learned_min)
|
||||
export const getJobChars = () => jget('/roster/job-characteristics')
|
||||
export const saveJobChars = (items) => jpost('/roster/job-characteristics', { items })
|
||||
// Chrono (boucle de capture) : début/fin réels d'un job → durée réelle (apprentissage)
|
||||
export const startJob = (job) => jpost('/roster/job/start', { job })
|
||||
export const finishJob = (job) => jpost('/roster/job/finish', { job })
|
||||
export const techSyncReport = () => jget('/dispatch/legacy-sync/tech-sync')
|
||||
export const techSyncApply = (ids) => jpost('/dispatch/legacy-sync/tech-sync/apply?ids=' + encodeURIComponent((ids || []).join(',')), {})
|
||||
// Retour au POOL legacy (action EXPLICITE) : désassigne (ERPNext) + réécrit le ticket à Tech Targo (3301) dans osTicket
|
||||
export const returnJobToPool = (job) => jpost('/dispatch/legacy-sync/return-to-pool?job=' + encodeURIComponent(job), {})
|
||||
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
|
||||
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ export function useSSE (opts = {}) {
|
|||
} catch {}
|
||||
})
|
||||
|
||||
// Événements nommés génériques : opts.listeners = { 'legacy-update': fn, ... }
|
||||
if (opts.listeners) {
|
||||
for (const [name, cb] of Object.entries(opts.listeners)) {
|
||||
es.addEventListener(name, (e) => { try { cb(JSON.parse(e.data)) } catch {} })
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = (e) => {
|
||||
connected.value = false
|
||||
if (opts.onError) opts.onError(e)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@
|
|||
<q-separator />
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="deep-orange-7" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="open_in_new" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch (détails & priorités)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
|
||||
|
|
@ -155,6 +157,14 @@
|
|||
<th class="tech-col"><div class="row items-center no-wrap">Ressource<q-space /><q-btn v-if="hiddenCount" flat dense round size="xs" :icon="showHidden ? 'visibility' : 'visibility_off'" :color="showHidden ? 'primary' : 'grey-6'" @click="showHidden = !showHidden"><q-badge floating color="grey-7" style="top:-7px;right:6px">{{ hiddenCount }}</q-badge><q-tooltip>{{ showHidden ? 'Cacher les ressources masquées' : (hiddenCount + ' masquée(s) — afficher en grisé') }}</q-tooltip></q-btn></div></th>
|
||||
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
|
||||
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
|
||||
<div v-if="capByDay[d.iso] && capByDay[d.iso].cap_h" class="cap-strip" @click.stop>
|
||||
<span v-for="s in capByDay[d.iso].segments" :key="s.key" class="cap-seg" :class="capSegClass(s)">{{ capFmt(s.free) }}
|
||||
<q-tooltip class="bg-grey-9">{{ s.label }} — <b>{{ capFmt(s.free) }} h libre</b> / {{ capFmt(s.cap) }} h · occupé {{ capFmt(s.used) }} h<br><span style="opacity:.8">{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'toutes ressources affichées' }} ({{ visibleTechs.length }} tech)</span></q-tooltip>
|
||||
</span>
|
||||
<q-icon v-if="capByDay[d.iso].overbooked" name="warning" color="negative" size="12px" class="cap-warn">
|
||||
<q-tooltip class="bg-grey-9">Surcharge : {{ capFmt(capByDay[d.iso].due_h) }} h dues > {{ capFmt(capByDay[d.iso].cap_h) }} h de capacité ({{ capByDay[d.iso].jobs_due }} job(s))</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
<div class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</div>
|
||||
</th>
|
||||
</tr>
|
||||
|
|
@ -172,6 +182,7 @@
|
|||
<span v-if="t.group" class="grp">{{ t.group }}</span>
|
||||
<span v-if="hoursOf(t.id)" class="th" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'">{{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
|
||||
<q-btn flat dense round size="9px" icon="timeline" color="indigo-5" @click.stop="openTimeline(t)"><q-tooltip>Timeline dispatch de {{ t.name }} — jobs de la semaine & priorités</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="9px" icon="phone_iphone" color="deep-purple-5" @click.stop="techAppLinkMenu(t)"><q-tooltip>Lien app terrain de {{ t.name }} (copier / envoyer par SMS)</q-tooltip></q-btn>
|
||||
<div class="tech-skills clk" @click.stop="openSkillEditor(t, $event)">
|
||||
<span v-for="sk in (t.skills || [])" :key="sk" class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}<span v-if="(t.skill_levels || {})[sk] || (t.skill_eff || {})[sk]" class="chip-lvl" :style="{ background: skillEffColor(t, sk) }"><q-tooltip class="bg-grey-9">Niveau {{ (t.skill_levels || {})[sk] || '—' }} · {{ effSuffix(skillEffOf(t, sk)) }}</q-tooltip>{{ (t.skill_levels || {})[sk] || '·' }}</span></span>
|
||||
<span v-if="!(t.skills || []).length" class="add-skill-hint">+ compétences</span>
|
||||
|
|
@ -553,6 +564,7 @@
|
|||
<!-- Carte des jobs à assigner : 1 pin par adresse (lettre = groupe de ≥2 jobs à la même adresse) → voir les régions, clic = sélectionner le groupe local -->
|
||||
<div v-show="assignPanel.showMap" class="assign-map-wrap" @mousedown.stop>
|
||||
<div ref="assignMapEl" class="assign-map"></div>
|
||||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
<div class="assign-map-cap">📍 Pins colorés par <b>compétence</b> · <b>lettre</b> = repère (même lettre dans la liste) · clic = sélectionne le(s) job(s)<span v-if="assignNoCoord" class="text-deep-orange-7"> · ⚠ {{ assignNoCoord }} sans coords</span></div>
|
||||
</div>
|
||||
<div class="assign-body">
|
||||
|
|
@ -574,7 +586,7 @@
|
|||
</div>
|
||||
<div class="assign-sub">
|
||||
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
|
||||
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · {{ j.duration_h || 1 }}h<span v-if="j.scheduled_date" :class="isOverdue(j.scheduled_date) ? 'text-deep-orange-7 text-weight-bold' : 'text-grey-7'"> · 📅 {{ fmtDueLabel(j.scheduled_date) }}</span>
|
||||
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · <span class="text-teal-9 text-weight-medium">≈ {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}<q-tooltip v-if="j.est_labels && j.est_labels.length" class="bg-grey-9" style="font-size:11px">Estimé (auto) : {{ j.est_labels.join(' + ') }}</q-tooltip></span><span v-if="j.scheduled_date" :class="isOverdue(j.scheduled_date) ? 'text-deep-orange-7 text-weight-bold' : 'text-grey-7'"> · 📅 {{ fmtDueLabel(j.scheduled_date) }}</span>
|
||||
</div>
|
||||
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
|
||||
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil…</div>
|
||||
|
|
@ -624,12 +636,124 @@
|
|||
</template>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-checkbox v-if="legacyPush.preview && legacyPush.preview.pushed" v-model="legacyPush.notify" dense size="sm" color="deep-orange" class="q-mr-auto" label="Notifier les techs par courriel">
|
||||
<q-tooltip class="bg-grey-9" max-width="320px">Envoie à chaque tech réassigné le courriel d'assignation (résumé + adresse + lien <b>Répondre</b>), identique au legacy.</q-tooltip>
|
||||
</q-checkbox>
|
||||
<q-btn flat label="Annuler" v-close-popup />
|
||||
<q-btn unelevated color="deep-orange" icon="cloud_upload" label="Écrire dans le legacy" :loading="legacyPush.applying" :disable="!legacyPush.preview || !legacyPush.preview.pushed" @click="applyLegacyPush" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Sélecteur d'emplacement (jobs « hors carte ») : carte bornée au sud de Montréal + recherche d'adresse RQA -->
|
||||
<q-dialog v-model="locPicker.open" @show="initLocMap" @hide="destroyLocMap">
|
||||
<q-card style="width: 700px; max-width: 96vw">
|
||||
<q-card-section class="row items-center q-pb-xs">
|
||||
<div class="text-subtitle2">📍 Situer le job <span class="text-grey-7">— {{ locPicker.subject }}</span></div>
|
||||
<q-space />
|
||||
<q-btn dense flat round icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-input dense outlined v-model="locPicker.search" placeholder="Chercher une adresse (base RQA Québec)…" class="q-mb-xs" :loading="locPicker.searching" @keyup.enter="locSearch" clearable>
|
||||
<template #append><q-btn dense flat icon="search" @click="locSearch" /></template>
|
||||
</q-input>
|
||||
<q-list v-if="locPicker.results.length" bordered dense class="loc-results q-mb-xs">
|
||||
<q-item v-for="(rr, i) in locPicker.results" :key="i" clickable @click="pickLocResult(rr)">
|
||||
<q-item-section>
|
||||
<q-item-label>{{ rr.address_full || ((rr.numero || '') + ' ' + (rr.rue || '')) }}</q-item-label>
|
||||
<q-item-label caption>{{ rr.ville }} {{ rr.code_postal }}<span v-if="rr.fiber_available" class="text-positive"> · fibre</span></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side><q-icon name="place" color="deep-orange-6" /></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div class="loc-map-wrap">
|
||||
<div ref="locMapEl" class="loc-map"></div>
|
||||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
<div class="loc-map-hint">Clic = situer · glisser le repère · <b>clic droit = Street View</b></div>
|
||||
</div>
|
||||
<q-input dense outlined v-model="locPicker.address" label="Adresse (modifiable, écrite sur le job)" class="q-mt-sm" />
|
||||
<div class="text-caption text-grey-6 q-mt-xs" v-if="locPicker.lat != null">Coordonnées : {{ locPicker.lat }}, {{ locPicker.lon }}</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Annuler" v-close-popup />
|
||||
<q-btn unelevated color="primary" icon="place" label="Utiliser cet emplacement" :loading="locPicker.saving" :disable="locPicker.lat == null" @click="saveLocPicker" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Synchroniser les techniciens : réconciliation staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech -->
|
||||
<q-dialog v-model="techSync.open">
|
||||
<q-card style="width: 720px; max-width: 96vw">
|
||||
<q-card-section class="row items-center q-pb-xs">
|
||||
<div class="text-subtitle2">👥 Synchroniser les techniciens</div>
|
||||
<q-space />
|
||||
<q-btn dense flat round icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section v-if="techSync.loading" class="text-center q-pa-lg"><q-spinner size="28px" color="primary" /></q-card-section>
|
||||
<template v-else-if="techSync.report">
|
||||
<q-card-section class="q-pt-none">
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Staff legacy actifs : <b>{{ techSync.report.counts.staff_active }}</b> · Fiches roster : <b>{{ techSync.report.counts.fiches }}</b> · Groupe Authentik <code>tech</code> : <b>{{ techSync.report.authentik ? techSync.report.counts.tech_group : '—' }}</b></div>
|
||||
<q-expansion-item default-opened dense icon="group_add" header-class="text-green-8" :label="`À ajouter au roster (${techSync.report.staff_missing_fiche.length})`">
|
||||
<div v-if="!techSync.report.staff_missing_fiche.length" class="text-grey-6 q-pa-sm">Aucun — le roster est à jour ✅</div>
|
||||
<q-list v-else dense bordered class="ts-list">
|
||||
<q-item v-for="s in techSync.report.staff_missing_fiche" :key="s.staff_id" tag="label">
|
||||
<q-item-section side><q-checkbox v-model="techSync.sel[s.staff_id]" dense color="green-7" /></q-item-section>
|
||||
<q-item-section><q-item-label>{{ s.name }} <span class="text-grey-5">#{{ s.staff_id }}</span></q-item-label><q-item-label caption>{{ s.email || '— sans courriel —' }}</q-item-label></q-item-section>
|
||||
<q-item-section side><q-chip dense size="sm" :color="s.in_tech_group ? 'green-2' : 'orange-2'" :text-color="s.in_tech_group ? 'green-9' : 'orange-9'">{{ s.in_tech_group ? 'accès tech ✓' : 'hors groupe tech' }}</q-chip></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item v-if="techSync.report.authentik && techSync.report.fiche_no_access.length" dense icon="lock" header-class="text-deep-orange-8" :label="`Fiches SANS accès Ops (${techSync.report.fiche_no_access.length})`">
|
||||
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Pas dans le groupe Authentik <code>tech</code> → à ajouter manuellement sur auth.targo.ca (lecture seule ici).</div>
|
||||
<q-list dense class="ts-list"><q-item v-for="t in techSync.report.fiche_no_access" :key="t.technician_id"><q-item-section><q-item-label>{{ t.name }}</q-item-label><q-item-label caption>{{ t.email }}</q-item-label></q-item-section></q-item></q-list>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item v-if="techSync.report.authentik && techSync.report.access_no_fiche.length" dense icon="help_outline" :label="`Dans le groupe tech, hors roster (${techSync.report.access_no_fiche.length})`">
|
||||
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Ont l'accès Ops mais aucune fiche (ex-employé ? courriel ≠ legacy ?).</div>
|
||||
<div class="q-pa-sm" style="font-size:11px;color:#78909c;line-height:1.6">{{ techSync.report.access_no_fiche.join(' · ') }}</div>
|
||||
</q-expansion-item>
|
||||
<q-banner v-if="!techSync.report.authentik" dense class="bg-orange-1 text-orange-9 q-mt-sm"><template #avatar><q-icon name="warning" /></template>Authentik non joignable — vérif d'accès indisponible, seul l'ajout au roster fonctionne.</q-banner>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Fermer" v-close-popup />
|
||||
<q-btn unelevated color="green-7" icon="group_add" :label="`Créer les fiches cochées (${techSyncSelCount})`" :loading="techSync.applying" :disable="!techSyncSelCount" @click="applyTechSync" />
|
||||
</q-card-actions>
|
||||
</template>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Tableur « durées par caractéristique » (additif) — édition inline -->
|
||||
<q-dialog v-model="jobChar.open">
|
||||
<q-card style="width: 860px; max-width: 97vw">
|
||||
<q-card-section class="row items-center q-pb-xs">
|
||||
<div class="text-subtitle2">⏱ Durées par caractéristique <span class="text-grey-6">— additif, hors transport</span></div>
|
||||
<q-space /><q-btn dense flat round icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section v-if="jobChar.loading" class="text-center q-pa-md"><q-spinner color="primary" size="26px" /></q-card-section>
|
||||
<q-card-section v-else class="q-pt-none">
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Durée d'un job = <b>1 base</b> + <b>caractéristiques</b> cochées (×qté), × vitesse du tech. Minutes éditables. Les valeurs <b>apprises</b> (geofence) viendront calibrer ces seeds par régression.</div>
|
||||
<table class="jc-table">
|
||||
<thead><tr><th>Catégorie</th><th>Libellé</th><th>Min</th><th>×Qté</th><th>Mots-clés (auto-détection)</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(it, i) in jobChar.items" :key="i" :class="'jc-' + it.cat">
|
||||
<td><q-select dense options-dense outlined v-model="it.cat" :options="['base', 'addon', 'modifier']" style="min-width:92px" /></td>
|
||||
<td><q-input dense outlined v-model="it.label" /></td>
|
||||
<td style="white-space:nowrap"><q-input dense outlined type="number" v-model.number="it.minutes" style="width:64px" /><span v-if="it.learned_min != null" class="jc-learn"> appris {{ it.learned_min }}m·{{ it.samples }}</span></td>
|
||||
<td class="text-center"><q-toggle dense size="sm" v-model="it.per_qty" /></td>
|
||||
<td><q-input dense outlined v-model="it.keywords" placeholder="ex. enfoui,enterré" /></td>
|
||||
<td><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="jobChar.items.splice(i, 1)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat dense icon="add" label="Ajouter une ligne" color="primary" @click="addJobCharRow" />
|
||||
<q-space />
|
||||
<q-btn flat label="Fermer" v-close-popup />
|
||||
<q-btn unelevated color="primary" icon="save" label="Enregistrer" :loading="jobChar.saving" @click="saveJobChar" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Timeline contextuelle d'une ressource : dispatch des jobs de la semaine visible (heures + priorités) -->
|
||||
<q-dialog v-model="timelineDlg.open">
|
||||
<q-card style="min-width:560px;max-width:780px">
|
||||
|
|
@ -736,7 +860,8 @@
|
|||
<!-- carte interactive : itinéraire ROUTIER réel + pins numérotés, navigable (zoom molette/boutons, déplacement) -->
|
||||
<div v-show="dayEditor.list.length" class="de-map-wrap">
|
||||
<div ref="dayMapEl" class="de-map-gl"></div>
|
||||
<div class="de-map-cap">🗺 Itinéraire routier · molette/boutons = zoom · glisser = déplacer<span v-if="dayNoCoord" class="text-deep-orange-7"> · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)</span></div>
|
||||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
<div class="de-map-cap">🗺 Itinéraire routier · zoom molette · <b>clic droit = Street View</b><span v-if="dayNoCoord" class="text-deep-orange-7"> · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)</span></div>
|
||||
</div>
|
||||
<div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
|
||||
<!-- liste éditable : flèches/glisser pour réordonner · durée en minutes · ✕ pour retirer -->
|
||||
|
|
@ -758,10 +883,14 @@
|
|||
<div class="col" style="min-width:0;cursor:pointer" @click="toggleJobDetail(j)"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip>
|
||||
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }} <q-icon name="info_outline" size="12px" class="text-grey-5" /></div>
|
||||
<div class="ellipsis text-grey-6" style="font-size:11px">{{ fmtHM(packedDay[i].startMin) }}–{{ fmtHM(packedDay[i].endMin) }}<span v-if="j.locked" class="text-deep-orange-7"> · 🔒 RDV fixe</span><span v-if="j.customer"> · {{ j.customer }}</span></div>
|
||||
<div v-if="j.address" class="ellipsis" style="font-size:11px;color:#90a4ae"><q-icon name="place" size="11px" /> {{ j.address }}<span v-if="!hasLL(j)" class="text-deep-orange-7"> · ⚠ sans coords (hors carte)</span></div>
|
||||
<div v-if="j.address || !hasLL(j)" class="ellipsis" style="font-size:11px;color:#90a4ae"><template v-if="j.address"><q-icon name="place" size="11px" /> {{ j.address }}</template><span v-if="!hasLL(j)" class="loc-pick-link text-deep-orange-7" @click.stop="openLocPicker(j)"> · ⚠ sans coords — 📍 situer</span></div>
|
||||
</div>
|
||||
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
|
||||
<!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) → gain de place ; priorité gérée au Dispatch -->
|
||||
<q-btn v-if="!j.actual_start" flat dense round size="sm" icon="play_arrow" color="green-6" @click="startJobChrono(j)"><q-tooltip>Démarrer (chrono réel → apprentissage)</q-tooltip></q-btn>
|
||||
<q-btn v-else-if="!j.actual_end" flat dense round size="sm" icon="stop_circle" color="deep-orange" class="chrono-run" @click="finishJobChrono(j)"><q-tooltip>Terminer (chrono réel)</q-tooltip></q-btn>
|
||||
<span v-else class="de-actual" :title="'Durée réelle mesurée'">⏱{{ j.actual_min != null ? j.actual_min : '?' }}m</span>
|
||||
<q-btn flat dense round size="sm" icon="edit_location_alt" :color="hasLL(j) ? 'blue-grey-5' : 'deep-orange'" @click="openLocPicker(j)"><q-tooltip>{{ hasLL(j) ? 'Repositionner sur la carte' : 'Situer sur la carte (sans coords)' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" :icon="j.locked ? 'lock' : 'lock_open'" :color="j.locked ? 'deep-orange' : 'grey-5'" @click="j.locked = !j.locked"><q-tooltip>{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="close" color="negative" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
|
||||
</div>
|
||||
|
|
@ -812,6 +941,8 @@ import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } fr
|
|||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
|
||||
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
|
||||
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
|
||||
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
|
||||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||||
|
|
@ -826,6 +957,31 @@ const techs = ref([])
|
|||
const templates = ref([])
|
||||
const assignments = ref([])
|
||||
const coverageData = ref([])
|
||||
// Dispo restante par jour × segment (AM/PM/Soir), calculée CLIENT-SIDE sur les ressources AFFICHÉES (visibleTechs)
|
||||
// → suit le filtre par compétence : cliquer « installation » ⇒ visibleTechs = techs installation ⇒ capacité = leurs shifts.
|
||||
// Soir (17-21h) RETIRÉ des blocs offerts : c'est une capacité de secours/urgence/garde uniquement, jamais proposée au client.
|
||||
const DAY_SEGS = [{ key: 'am', label: 'AM', from: 8, to: 12 }, { key: 'pm', label: 'PM', from: 12, to: 17 }]
|
||||
const _segOv = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
|
||||
const _r1 = (x) => Math.round(x * 10) / 10
|
||||
const _toH = (t) => { if (t == null) return 0; if (typeof t === 'number') return t; const p = String(t).split(':'); return (Number(p[0]) || 0) + (Number(p[1]) || 0) / 60 }
|
||||
const capByDay = computed(() => {
|
||||
const out = {}; const tids = visibleTechs.value.map(t => t.id); const cbtd = cellsByTechDay.value; const tpl = tplByName.value
|
||||
for (const d of dayList.value) {
|
||||
const segs = DAY_SEGS.map(s => ({ key: s.key, label: s.label, cap: 0, used: 0, free: 0 }))
|
||||
for (const tid of tids) {
|
||||
const asgs = (cbtd[tid] && cbtd[tid][d.iso]) || [] // quarts publiés/proposés du tech ce jour
|
||||
for (const a of asgs) { const t = tpl[a.shift]; if (!t || t.on_call) continue; const s = _toH(t.start_time), e = _toH(t.end_time); if (!(e > s)) continue; for (let i = 0; i < DAY_SEGS.length; i++) segs[i].cap += _segOv(s, e, DAY_SEGS[i].from, DAY_SEGS[i].to) }
|
||||
const occ = occByTechDay.value[tid + '|' + d.iso] // jobs placés (blocs horaires)
|
||||
if (occ && occ.blocks) for (const b of occ.blocks) for (let i = 0; i < DAY_SEGS.length; i++) segs[i].used += _segOv(b.s, b.e, DAY_SEGS[i].from, DAY_SEGS[i].to)
|
||||
}
|
||||
let due_h = 0, jobs_due = 0 // charge DUE = pool non assigné ce jour (filtré par compétence si actif)
|
||||
for (const j of (assignPanel.jobs || [])) { if (j.scheduled_date !== d.iso) continue; if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue; due_h += (j.est_min ? j.est_min / 60 : Number(j.duration_h || j.dur || 0)); jobs_due++ }
|
||||
segs.forEach(s => { s.cap = _r1(s.cap); s.used = _r1(s.used); s.free = _r1(s.cap - s.used) })
|
||||
const cap_h = _r1(segs.reduce((x, s) => x + s.cap, 0)); const used_h = _r1(segs.reduce((x, s) => x + s.used, 0))
|
||||
out[d.iso] = { segments: segs, cap_h, used_h, free_h: _r1(cap_h - used_h), due_h: _r1(due_h), jobs_due, overbooked: cap_h > 0 && due_h > cap_h }
|
||||
}
|
||||
return out
|
||||
})
|
||||
const dailyStats = ref([])
|
||||
const solverStats = ref(null)
|
||||
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
|
||||
|
|
@ -1013,9 +1169,9 @@ const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours
|
|||
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { err(e) } finally { assignPanel.loading = false } }
|
||||
|
||||
// Write-back legacy : aperçu (dryRun, 0 écriture) → confirmation → écrit ticket.assign_to dans osTicket.
|
||||
const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null })
|
||||
const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null, notify: true })
|
||||
async function openLegacyPush () { legacyPush.open = true; legacyPush.loading = true; legacyPush.preview = null; try { legacyPush.preview = await roster.pushLegacyPreview() } catch (e) { err(e) } finally { legacyPush.loading = false } }
|
||||
async function applyLegacyPush () { legacyPush.applying = true; try { const r = await roster.pushLegacyApply(); $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Legacy : ${r.pushed} ticket(s) réassigné(s)` + (r.mismatch ? ` · ${r.mismatch} ignoré(s) (nom ≠)` : '') + (r.errors ? ` · ${r.errors} erreur(s)` : '') }); legacyPush.open = false } catch (e) { err(e) } finally { legacyPush.applying = false } }
|
||||
async function applyLegacyPush () { legacyPush.applying = true; try { const r = await roster.pushLegacyApply(legacyPush.notify); $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Legacy : ${r.pushed} ticket(s) réassigné(s)` + (legacyPush.notify && r.notified != null ? ` · ${r.notified} avisé(s) par courriel (boîte du tech)` : '') + (r.mismatch ? ` · ${r.mismatch} ignoré(s) (nom ≠)` : '') + (r.errors ? ` · ${r.errors} erreur(s)` : '') }); if (legacyPush.notify && r.notify_errors && r.notify_errors.length) { $q.notify({ type: 'warning', icon: 'mark_email_unread', message: `⚠ ${r.notify_errors.length} avis NON envoyé(s) — ${(r.notify_errors[0] && r.notify_errors[0].error) || ''}`, timeout: 6000 }) } legacyPush.open = false } catch (e) { err(e) } finally { legacyPush.applying = false } }
|
||||
// X sur une ligne de l'aperçu → désassigne le job dans Ops (retour au pool) → l'exclut du push, puis rafraîchit l'aperçu + la grille.
|
||||
async function removeFromPush (s) {
|
||||
if (!s || !s.job) { $q.notify({ type: 'warning', message: 'Job introuvable' }); return }
|
||||
|
|
@ -1027,7 +1183,7 @@ async function removeFromPush (s) {
|
|||
$q.notify({ type: 'info', message: 'Job désassigné (retour au pool) — exclu du legacy', timeout: 2200 })
|
||||
} catch (e) { err(e) } finally { legacyPush.applying = false }
|
||||
}
|
||||
const assignSort = ref('group') // group (parent-enfant) | skill | date | city | priority
|
||||
const assignSort = ref('date') // défaut = date DUE (facilite le dispatch : aujourd'hui/en retard d'abord). Autres : group | skill | city | priority
|
||||
const assignSortDir = ref('asc') // sens du tri (asc/desc) — surtout pour la date
|
||||
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||
function jobCity (j) {
|
||||
|
|
@ -1044,7 +1200,11 @@ const assignJobsFiltered = computed(() => { const f = assignTypeFilter.value; if
|
|||
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
|
||||
// Date DUE (≠ date de création) : étiquette relative pour grouper/afficher (En retard / Aujourd'hui / future).
|
||||
function isOverdue (d) { return !!d && d !== 'Sans date' && d < todayISO() }
|
||||
function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due'; const t = todayISO(); if (d < t) return d.slice(5) + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d.slice(5) }
|
||||
function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due'; const t = todayISO(); if (d < t) return d + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d }
|
||||
// Couleur d'un segment de capacité (heures-tech libres restantes) : rouge=plein, orange=presque plein, vert=ok, gris=aucun quart.
|
||||
function capSegClass (s) { if (!s || s.cap <= 0) return 'cap-none'; if (s.free <= 0) return 'cap-full'; if (s.free < 1.5) return 'cap-low'; return 'cap-ok' }
|
||||
const capFmt = (h) => (h === 0 ? '0' : (Number.isInteger(h) ? String(h) : h.toFixed(1)))
|
||||
const fmtMin = (m) => { m = Math.round(Number(m) || 0); const h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? String(mm).padStart(2, '0') : '')) : (mm + 'min') }
|
||||
const assignGroups = computed(() => {
|
||||
const jobs = assignJobsFiltered.value
|
||||
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation…), ordonné par step_order
|
||||
|
|
@ -1140,6 +1300,127 @@ function groupLabel (j) { return assignLocations.value.byName[j.name] || '' } //
|
|||
|
||||
// ── Carte des jobs à assigner (Mapbox GL) : 1 pin par adresse, lettre = groupe ; clic = sélectionne les jobs co-localisés ──
|
||||
const assignMapEl = ref(null); let _assignMap = null, _assignMapRO = null
|
||||
|
||||
// ── Bascule vue SATELLITE (couche raster Mapbox superposée SOUS les pins ; pas de setStyle = pins conservés) ──
|
||||
// Préférence partagée par les 2 cartes (panneau + journée), persistée. addSatLayer() est appelé dans chaque on('load')
|
||||
// AVANT les couches de pins → le raster reste dessous. toggleSat() bascule la visibilité sur les cartes vivantes.
|
||||
const satView = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
|
||||
function addSatLayer (map) {
|
||||
try {
|
||||
if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', url: 'mapbox://mapbox.satellite', tileSize: 256 })
|
||||
if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: satView.value ? 'visible' : 'none' } })
|
||||
} catch (e) { /* style pas prêt — ignoré */ }
|
||||
}
|
||||
function applySat (map) { try { if (map && map.getLayer && map.getLayer('sat')) map.setLayoutProperty('sat', 'visibility', satView.value ? 'visible' : 'none') } catch (e) {} }
|
||||
function toggleSat () { satView.value = !satView.value; try { localStorage.setItem('plan_sat', satView.value ? '1' : '0') } catch (e) {} applySat(_assignMap); applySat(_dayMap); applySat(_locMap) }
|
||||
|
||||
// Clic DROIT sur une carte → Google Street View à la position cliquée (nouvel onglet). Branché sur les 3 cartes.
|
||||
function openStreetView (lng, lat) { window.open(`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}`, '_blank', 'noopener') }
|
||||
|
||||
// ── SÉLECTEUR D'EMPLACEMENT (jobs « hors carte ») : carte Mapbox bornée au sud de Montréal (Valleyfield ↔ Lacolle ↔
|
||||
// frontière US). Clic / glisser le repère OU recherche d'adresse RQA → coords + adresse, écrits sur le Dispatch Job. ──
|
||||
const locMapEl = ref(null); let _locMap = null, _locMarker = null
|
||||
const TERR_BOUNDS = [[-74.95, 44.95], [-73.0, 45.55]] // SW (Valleyfield/frontière) → NE
|
||||
const locPicker = reactive({ open: false, job: null, subject: '', lat: null, lon: null, address: '', search: '', results: [], searching: false, saving: false })
|
||||
function openLocPicker (j) {
|
||||
locPicker.job = j.name; locPicker.subject = j.subject || j.name
|
||||
locPicker.lat = hasLL(j) ? +j.lat : null; locPicker.lon = hasLL(j) ? +j.lon : null
|
||||
locPicker.address = j.address || ''; locPicker.search = j.address || ''; locPicker.results = []
|
||||
locPicker.open = true
|
||||
}
|
||||
async function initLocMap () {
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !locMapEl.value) return
|
||||
if (_locMap) { _locMap.resize(); return }
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_locMap = new mapboxgl.Map({ container: locMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5 })
|
||||
_locMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
_locMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
_locMap.on('load', () => {
|
||||
_locMap.resize(); addSatLayer(_locMap)
|
||||
if (locPicker.lat == null) _locMap.fitBounds(TERR_BOUNDS, { padding: 18, duration: 0 }) // zoom sur la région au sud de Montréal
|
||||
else placeLocMarker(locPicker.lon, locPicker.lat, false)
|
||||
})
|
||||
_locMap.on('click', (e) => placeLocMarker(e.lngLat.lng, e.lngLat.lat, true))
|
||||
}
|
||||
function placeLocMarker (lon, lat, doReverse) {
|
||||
locPicker.lon = +(+lon).toFixed(6); locPicker.lat = +(+lat).toFixed(6)
|
||||
if (!_locMarker) {
|
||||
_locMarker = new window.mapboxgl.Marker({ draggable: true, color: '#e8590c' }).setLngLat([lon, lat]).addTo(_locMap)
|
||||
_locMarker.on('dragend', () => { const ll = _locMarker.getLngLat(); locPicker.lon = +ll.lng.toFixed(6); locPicker.lat = +ll.lat.toFixed(6); reverseGeocode(ll.lng, ll.lat) })
|
||||
} else _locMarker.setLngLat([lon, lat])
|
||||
if (doReverse) reverseGeocode(lon, lat)
|
||||
}
|
||||
async function reverseGeocode (lon, lat) {
|
||||
if (!MAPBOX_TOKEN) return
|
||||
try {
|
||||
const r = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${lon},${lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`)
|
||||
const j = await r.json(); const f = (j.features || [])[0]
|
||||
if (f && f.place_name) locPicker.address = f.place_name.replace(/, Canada$/, '')
|
||||
} catch (e) { /* non bloquant */ }
|
||||
}
|
||||
async function locSearch () {
|
||||
const q = (locPicker.search || '').trim(); if (q.length < 3) return
|
||||
locPicker.searching = true
|
||||
try { const r = await addressApi.conformityCandidates(q); locPicker.results = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude)).slice(0, 8) } catch (e) { err(e) } finally { locPicker.searching = false }
|
||||
}
|
||||
function pickLocResult (rr) {
|
||||
const lon = +rr.longitude, lat = +rr.latitude; if (!isFinite(lon) || !isFinite(lat)) return
|
||||
locPicker.address = rr.address_full || [rr.numero, rr.rue, rr.ville, rr.code_postal].filter(Boolean).join(' ')
|
||||
locPicker.results = []
|
||||
if (_locMap) { _locMap.easeTo({ center: [lon, lat], zoom: 16, duration: 500 }); placeLocMarker(lon, lat, false) } else { locPicker.lon = lon; locPicker.lat = lat }
|
||||
}
|
||||
async function saveLocPicker () {
|
||||
if (locPicker.lat == null || locPicker.lon == null) { $q.notify({ type: 'warning', message: 'Choisis un point sur la carte' }); return }
|
||||
locPicker.saving = true
|
||||
try {
|
||||
await roster.setJobLocation(locPicker.job, locPicker.lat, locPicker.lon, locPicker.address)
|
||||
const j = dayEditor.list.find(x => x.name === locPicker.job); if (j) { j.lat = locPicker.lat; j.lon = locPicker.lon; if (locPicker.address) j.address = locPicker.address }
|
||||
locPicker.open = false
|
||||
await reloadOccupancy(); if (_dayMap) refreshDayMap()
|
||||
$q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' })
|
||||
} catch (e) { err(e) } finally { locPicker.saving = false }
|
||||
}
|
||||
function destroyLocMap () { if (_locMarker) { try { _locMarker.remove() } catch (e) {} _locMarker = null } if (_locMap) { try { _locMap.remove() } catch (e) {} _locMap = null } }
|
||||
// init/destroy via @show/@hide du q-dialog (le conteneur a sa taille finale à @show → carte fiable)
|
||||
|
||||
// ── Tableur « durées par caractéristique » (additif, hors transport) — éditable inline, seed + apprentissage ──
|
||||
const jobChar = reactive({ open: false, loading: false, saving: false, items: [] })
|
||||
async function openJobChar () { jobChar.open = true; jobChar.loading = true; try { const r = await roster.getJobChars(); jobChar.items = r.items || [] } catch (e) { err(e) } finally { jobChar.loading = false } }
|
||||
function addJobCharRow () { jobChar.items.push({ id: 'c' + Date.now().toString(36), cat: 'addon', label: '', minutes: 30, per_qty: false, keywords: '', learned_min: null, samples: 0 }) }
|
||||
async function saveJobChar () { jobChar.saving = true; try { const r = await roster.saveJobChars(jobChar.items); $q.notify({ type: r.ok ? 'positive' : 'negative', message: r.ok ? `Table enregistrée (${r.count} lignes)` : 'Échec d\'enregistrement' }); if (r.ok) jobChar.open = false } catch (e) { err(e) } finally { jobChar.saving = false } }
|
||||
|
||||
// Lien app terrain d'un tech : copie le lien (PWA /field?t=) + propose l'envoi SMS au tech (sans build natif requis).
|
||||
async function techAppLinkMenu (t) {
|
||||
try {
|
||||
const r = await roster.techAppLink(t.id); if (!r || !r.ok) { err(new Error('lien app indisponible')); return }
|
||||
try { await navigator.clipboard.writeText(r.url) } catch (e) {}
|
||||
const actions = [{ label: 'OK', color: 'white' }]
|
||||
if (r.phone) actions.unshift({ label: '📲 SMS au tech', color: 'yellow', handler: async () => { try { await sendSmsViaHub(r.phone, 'Ton app terrain Targo (interventions du jour) : ' + r.url, ''); $q.notify({ type: 'positive', message: 'Lien envoyé par SMS à ' + r.name }) } catch (e) { err(e) } } })
|
||||
$q.notify({ message: '📱 Lien app copié' + (r.phone ? '' : ' (pas de téléphone au dossier)') + ' — ' + r.url, timeout: 9000, multiLine: true, actions })
|
||||
} catch (e) { err(e) }
|
||||
}
|
||||
|
||||
// ── Synchroniser les techniciens : rapport de réconciliation (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) ──
|
||||
const techSync = reactive({ open: false, loading: false, applying: false, report: null, sel: {} })
|
||||
const techSyncSelCount = computed(() => Object.values(techSync.sel).filter(Boolean).length)
|
||||
async function openTechSync () {
|
||||
techSync.open = true; techSync.loading = true; techSync.report = null; techSync.sel = {}
|
||||
try {
|
||||
const r = await roster.techSyncReport(); techSync.report = r
|
||||
for (const s of (r.staff_missing_fiche || [])) techSync.sel[s.staff_id] = true // candidats cochés par défaut
|
||||
} catch (e) { err(e) } finally { techSync.loading = false }
|
||||
}
|
||||
async function applyTechSync () {
|
||||
const ids = Object.entries(techSync.sel).filter(([, v]) => v).map(([k]) => k)
|
||||
if (!ids.length) return
|
||||
techSync.applying = true
|
||||
try {
|
||||
const r = await roster.techSyncApply(ids)
|
||||
$q.notify({ type: r.created ? 'positive' : 'info', icon: 'group_add', message: `${r.created} fiche(s) technicien créée(s)` + (r.results ? ` · ${r.results.filter(x => x.skipped).length} déjà présente(s)` : '') })
|
||||
techSync.open = false
|
||||
await loadBase() // recharge la liste des techniciens dans la grille
|
||||
} catch (e) { err(e) } finally { techSync.applying = false }
|
||||
}
|
||||
const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01)
|
||||
const assignNoCoord = computed(() => assignPanel.jobs.filter(j => !_hasJobLL(j)).length)
|
||||
function assignStops () { return assignLocations.value.stops }
|
||||
|
|
@ -1160,9 +1441,11 @@ async function initAssignMap () {
|
|||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_assignMap = new mapboxgl.Map({ container: assignMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
|
||||
_assignMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
_assignMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
_assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
|
||||
_assignMap.on('load', () => {
|
||||
_assignMap.resize()
|
||||
addSatLayer(_assignMap) // couche satellite (sous les pins), masquée par défaut
|
||||
_assignMap.addSource('aj', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
_assignMap.addLayer({ id: 'aj-c', type: 'circle', source: 'aj', paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } })
|
||||
_assignMap.addLayer({ id: 'aj-l', type: 'symbol', source: 'aj', layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
|
||||
|
|
@ -1345,9 +1628,11 @@ async function initDayMap () {
|
|||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
|
||||
_dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
|
||||
_dayMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
|
||||
_dayMap.on('load', () => {
|
||||
_dayMap.resize()
|
||||
addSatLayer(_dayMap) // couche satellite (sous l'itinéraire et les pins), masquée par défaut
|
||||
_dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
_dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } })
|
||||
_dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } })
|
||||
|
|
@ -1400,8 +1685,16 @@ function destroyDayMap () {
|
|||
watch(() => dayEditor.open, (open) => { if (open) nextTick(() => setTimeout(initDayMap, 250)); else destroyDayMap() })
|
||||
watch(() => dayEditor.list.map(j => j.name).join(','), () => { if (_dayMap) { clearTimeout(_dirTimer); _dirTimer = setTimeout(refreshDayMap, 500) } })
|
||||
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
|
||||
// Chrono (boucle de capture) : début/fin réels → durée mesurée (alimente l'apprentissage des durées par type×tech)
|
||||
async function startJobChrono (j) { try { const r = await roster.startJob(j.name); j.actual_start = r.actual_start || '1'; j.actual_end = ''; $q.notify({ type: 'positive', icon: 'play_arrow', message: 'Job démarré (chrono)', timeout: 1500 }) } catch (e) { err(e) } }
|
||||
async function finishJobChrono (j) { try { const r = await roster.finishJob(j.name); j.actual_end = '1'; j.actual_min = r.actual_minutes; $q.notify({ type: 'positive', icon: 'check', message: `Terminé · ${r.actual_minutes} min réels`, timeout: 2500 }); reloadOccupancy() } catch (e) { err(e) } }
|
||||
async function removeFromDay (j) {
|
||||
try { await roster.unassignJobRoster(j.name); dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek(); $q.notify({ type: 'info', message: 'Retiré du tech (retour au pool « à assigner »)', timeout: 2200 }) } catch (e) { err(e) }
|
||||
// Action EXPLICITE → renvoie aussi le ticket au pool « Tech Targo » (3301) DANS Legacy (write-back symétrique).
|
||||
try {
|
||||
const r = await roster.returnJobToPool(j.name)
|
||||
dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek()
|
||||
$q.notify({ type: 'info', message: (r && r.returned_to_pool) ? 'Retiré du tech — ticket renvoyé au pool « Tech Targo » dans Legacy' : 'Retiré du tech (retour au pool « à assigner »)', timeout: 2600 })
|
||||
} catch (e) { err(e) }
|
||||
}
|
||||
async function saveDayOrder () {
|
||||
dayEditor.saving = true
|
||||
|
|
@ -2068,7 +2361,31 @@ function onKey (e) {
|
|||
}
|
||||
}
|
||||
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
|
||||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } openAssignPanel() /* panneau « Jobs à assigner » ouvert par défaut (fermable) */ })
|
||||
// ── Veille Legacy → Ops (SSE topic 'dispatch') : reflète fermeture / réassignation hors-Ops / activité en direct ──
|
||||
let _legacyRefreshT = null
|
||||
function scheduleLegacyRefresh () {
|
||||
clearTimeout(_legacyRefreshT)
|
||||
_legacyRefreshT = setTimeout(async () => {
|
||||
try { await reloadOccupancy(); if (assignPanel.open) { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } } catch (e) { /* non bloquant */ }
|
||||
}, 700)
|
||||
}
|
||||
function onLegacyUpdate (data) {
|
||||
const ch = (data && data.changes) || []
|
||||
if (!ch.length) return
|
||||
const closed = ch.filter(c => c.kind === 'closed').length
|
||||
const taken = ch.filter(c => c.kind === 'taken')
|
||||
const moved = ch.filter(c => c.kind === 'reassigned' && !c.pool)
|
||||
const stat = ch.filter(c => c.kind === 'status').length
|
||||
if (closed) $q.notify({ type: 'info', icon: 'task_alt', message: `Legacy : ${closed} ticket(s) fermé(s) — retiré(s) du planning`, timeout: 3500 })
|
||||
if (taken.length) $q.notify({ type: 'info', icon: 'person_remove', message: `Legacy : ${taken.length} ticket(s) pris par un tech dans Legacy — retiré(s) du pool`, timeout: 4000 })
|
||||
for (const m of moved) $q.notify({ type: 'warning', icon: 'swap_horiz', message: `Ticket #${m.ticket} réassigné HORS-Ops (staff ${m.to_staff}) — à vérifier`, timeout: 6000 })
|
||||
if (stat) $q.notify({ type: 'info', icon: 'sync', message: `Legacy : ${stat} changement(s) de statut`, timeout: 3000 })
|
||||
for (const c of ch) { if (c.job) flashJob.value = c.job }
|
||||
scheduleLegacyRefresh()
|
||||
}
|
||||
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
|
||||
|
||||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } openAssignPanel(); /* panneau « Jobs à assigner » ouvert par défaut (fermable) */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ })
|
||||
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) })
|
||||
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
|
||||
</script>
|
||||
|
|
@ -2083,7 +2400,31 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
|
|||
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
|
||||
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
|
||||
.assign-body { overflow: auto; padding: 5px; flex: 1 1 auto; min-height: 60px; }
|
||||
.assign-map-wrap { flex: 0 0 auto; padding: 0 5px 4px; }
|
||||
.assign-map-wrap { flex: 0 0 auto; padding: 0 5px 4px; position: relative; }
|
||||
.map-sat-btn { position: absolute; top: 7px; left: 11px; z-index: 3; background: rgba(255,255,255,.9); }
|
||||
.loc-map-wrap { position: relative; }
|
||||
.loc-map { height: 360px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||||
.loc-map-hint { position: absolute; bottom: 8px; left: 8px; background: rgba(255,255,255,.88); font-size: 11px; padding: 2px 7px; border-radius: 4px; color: #555; pointer-events: none; }
|
||||
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
|
||||
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
|
||||
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
|
||||
.cap-strip { display: flex; gap: 2px; justify-content: center; align-items: center; margin-top: 2px; }
|
||||
.cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; }
|
||||
.cap-ok { background: #c8e6c9; color: #1b5e20; }
|
||||
.cap-low { background: #ffe0b2; color: #e65100; }
|
||||
.cap-full { background: #ffcdd2; color: #b71c1c; }
|
||||
.cap-none { background: #eceff1; color: #b0bec5; }
|
||||
.cap-warn { margin-left: 1px; }
|
||||
.de-actual { font-size: 11px; font-weight: 700; color: #00695c; background: #e0f2f1; border-radius: 4px; padding: 2px 5px; white-space: nowrap; }
|
||||
.chrono-run { animation: chronopulse 1.4s ease-in-out infinite; }
|
||||
@keyframes chronopulse { 0%, 100% { opacity: 1 } 50% { opacity: .4 } }
|
||||
.jc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.jc-table th { text-align: left; color: #607d8b; font-weight: 600; padding: 3px 4px; border-bottom: 1px solid #e0e0e0; }
|
||||
.jc-table td { padding: 2px 4px; vertical-align: middle; }
|
||||
.jc-table tr.jc-base td:first-child { border-left: 3px solid #1565c0; }
|
||||
.jc-table tr.jc-addon td:first-child { border-left: 3px solid #00897b; }
|
||||
.jc-table tr.jc-modifier td:first-child { border-left: 3px solid #8e24aa; }
|
||||
.jc-learn { font-size: 10px; color: #2e7d32; white-space: nowrap; }
|
||||
.assign-map { height: 230px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||||
.assign-map-cap { font-size: 10px; color: #607d8b; padding: 2px 2px 0; line-height: 1.3; }
|
||||
.assign-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; border-bottom: 1px solid #eee; flex: 0 0 auto; }
|
||||
|
|
@ -2174,7 +2515,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
|
|||
.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; }
|
||||
.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
|
||||
/* minimap du jour (territoire des arrêts) */
|
||||
.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; }
|
||||
.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; position: relative; }
|
||||
.de-map-gl { width: 100%; height: 240px; }
|
||||
.de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; }
|
||||
.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; }
|
||||
|
|
|
|||
52
docs/field-tech-app.md
Normal file
52
docs/field-tech-app.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# App technicien & capture passive du temps (durées apprises)
|
||||
|
||||
> But : mesurer **sans effort tech** le temps réel passé par job → apprendre les durées par **type × technicien** → alimenter le recommandeur « prochaine dispo » et le solveur. Principe directeur : **aucun tap dédié** — la durée se *déduit* d'événements que le tech produit déjà + géorepérage GPS automatique.
|
||||
|
||||
## 1. Modèle « fil de checkpoints »
|
||||
Chaque job accumule des **checkpoints** horodatés (et géolocalisés). La durée réelle = **1er checkpoint sur place → dernier** (ou entrée→sortie de géorepérage).
|
||||
|
||||
| Type de checkpoint | Source | Effort tech |
|
||||
|---|---|---|
|
||||
| `geo_enter` / `geo_exit` | Géorepérage GPS (zone de l'adresse) | **nul** (auto) |
|
||||
| `scan` (série / MAC / code-barre) | Caméra app (modem, ONU, mesh, STB) | déjà fait (activation) |
|
||||
| `photo` / `signature` | App | déjà fait |
|
||||
| `reply` | Réponse au ticket | déjà fait |
|
||||
| `manual_start` / `manual_finish` | Override répartiteur (Ops) ou tech | filet de sécurité |
|
||||
|
||||
**Dérivation** : `actual_start` = 1er checkpoint « sur place » (geo_enter, ou 1er scan/photo/reply à < R m de l'adresse) ; `actual_end` = dernier ; `actual_minutes = end − start`. Les checkpoints hors-zone (> R m) sont ignorés pour la durée mais conservés pour l'audit.
|
||||
|
||||
## 2. Backend (cette itération — indépendant de l'app)
|
||||
- **Endpoint public token-gated** `POST /field/checkpoint` `{ token, type, ts, lat, lon, acc, ref }` → vérifie le token (HMAC du job), enregistre le checkpoint, recalcule `actual_start/end` du Dispatch Job, ajoute une ligne d'audit dans `completion_notes`. Réponse : `{ ok, on_site, distance_m, minutes }`.
|
||||
- **Lecture** `GET /field/job?t=<token>` → infos job (sujet, client, adresse, coords, état) pour l'app.
|
||||
- **Token** = `base64url(jobName).hmac12` signé (`crypto`, secret stable du hub) → stateless, valable pour tout job, aucun champ DB ni login.
|
||||
- `/field` + `/field/*` câblés **publics** dans `server.js` (calqués sur `/book`, hors forwardAuth Authentik).
|
||||
|
||||
## 3. App Capacitor (projet natif dédié — `apps/field-tech/`)
|
||||
Véhicule unique pour les techs (remplace le mobile legacy) :
|
||||
- **Géorepérage arrière-plan économe** : `@transistorsoft/capacitor-background-geolocation` (référence batterie + geofencing natif iOS/Android) — ou `@capacitor-community/background-geolocation` pour démarrer. On enregistre un geofence par job du jour ; `enter`/`exit` → POST `/field/checkpoint`.
|
||||
- **Scan caméra** : `@capacitor-mlkit/barcode-scanning` → série/MAC → checkpoint `scan` **et** push vers GenieACS (par MAC) — réunit l'item roadmap « scan série/MAC des devices ».
|
||||
- **Offline-first** : file d'attente locale (Preferences/SQLite) → rejoue les checkpoints à la reconnexion.
|
||||
- **Auth** : login Authentik (groupe `tech`) → l'app reçoit ses jobs du jour + un token par job ; ou device-token. (À finaliser.)
|
||||
- **Push** : web-push/FCM (déjà côté legacy) pour les nouvelles assignations.
|
||||
- Builds : iOS (TestFlight) + Android (APK interne) ; permissions localisation « toujours » + caméra.
|
||||
|
||||
## 4. Amorçage SANS app (mineur legacy — cette itération)
|
||||
Estimer une 1re durée par ticket depuis les événements legacy **déjà horodatés** : `ticket_msg.date_orig` du staff (réponses), création `device` (scan série), activation Ministra. Fenêtre sur-site ≈ (dernière activité − première) le même jour, filtrée < 8 h (exclut le multi-jour). **Bruité à l'unité, mais l'agrégat (médiane par type) donne une baseline d'apprentissage dès aujourd'hui.** Rapport `GET /dispatch/legacy-sync/mine-durations` (lecture seule).
|
||||
|
||||
## 5. Boucle d'apprentissage (consomme les durées)
|
||||
1. Capture (checkpoints app + override Ops + baseline minée) → `actual_minutes` par job.
|
||||
2. Agrégation : **médiane / p75 par sous-type × tech** (sous-types fins par mots-clés du sujet : « modem » vs « fil/fibre/drop » vs « ONT » vs « install »).
|
||||
3. Remplace la table statique `DUR` (util/legacy-parse) + affine `skill_eff` (facteur vitesse par tech×compétence).
|
||||
4. Le recommandeur « prochaine dispo » (`bookingSlots/fitBooking`) utilise la durée apprise ajustée au tech.
|
||||
|
||||
## 6. Phasage
|
||||
- **P1 (maintenant)** : backend checkpoints (§2) + mineur legacy (§4).
|
||||
- **P2** : recommandeur « prochaine dispo » (UI Ops) + sous-types fins + durées seed.
|
||||
- **P3** : app Capacitor (§3) — scaffold → geofence → scan → offline → stores.
|
||||
- **P4** : agrégation apprise (§5) qui remplace les seeds, une fois les données accumulées.
|
||||
|
||||
## 7. Sécurité / contraintes
|
||||
- Endpoint checkpoint **public mais token-gaté** (HMAC par job) ; pas de PII dans le token.
|
||||
- N'écrit QUE sur le Dispatch Job (ERPNext) ; ne touche pas le legacy en écriture.
|
||||
- GPS = **preuve + contrôle de distance**, pas du tracking continu serveur (le geofencing reste sur l'appareil).
|
||||
- Le tap répartiteur ▶/⏹ (Ops) reste comme override/fallback.
|
||||
|
|
@ -5,7 +5,8 @@
|
|||
* de l'app (aucun GRANT à modifier ; le user du hub @10.100.5.61 reste SELECT-only).
|
||||
*
|
||||
* Actions (POST, x-www-form-urlencoded ou JSON), header `X-Ops-Token: <token>` OBLIGATOIRE :
|
||||
* action=reassign ticket_id, staff_id, [assist_ids=CSV] → assign_to + participant(assistants) + followed_by + ticket_msg
|
||||
* action=reassign ticket_id, staff_id, [assist_ids=CSV] [notify=1] [address] → assign_to + participant(assistants) + followed_by + ticket_msg
|
||||
* notify=1 → courriel d'assignation au tech (résumé + lien Répondre + Adresse), via PHPMailer6 OAuth2 (ops_secret.php)
|
||||
* action=close ticket_id → status=closed + date_closed + closed_by + réouverture enfants + ticket_msg
|
||||
*
|
||||
* FIDÈLE à ticket_view.php : format participant `-id-;-id-`, followers JSON, log CHANGE LOG, respect du lock_name,
|
||||
|
|
@ -15,11 +16,11 @@
|
|||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// ─── à remplir sur le serveur (hors repo) ───
|
||||
$OPS_TOKEN = '__OPS_TOKEN__';
|
||||
// ─── secrets : fichier serveur HORS REPO `ops_secret.php` (à côté de ce fichier) qui définit :
|
||||
// $OPS_TOKEN, $DB_USER, $DB_PASS + creds OAuth Gmail $oauthEmail,$clientId,$clientSecret,$refreshToken
|
||||
// (réutilise les mêmes creds que ticket_view.php). JAMAIS committé.
|
||||
require __DIR__ . '/ops_secret.php';
|
||||
$DB_HOST = '10.100.80.100';
|
||||
$DB_USER = '__DB_USER__';
|
||||
$DB_PASS = '__DB_PASS__';
|
||||
$DB_NAME = 'gestionclient';
|
||||
$OPS_STAFF = 3301; // auteur des entrées ticket_msg (Tech Targo)
|
||||
|
||||
|
|
@ -83,7 +84,7 @@ if ($action === 'close') {
|
|||
// ─── reassign (+ assistants) ───
|
||||
$staff = (int)($_POST['staff_id'] ?? 0);
|
||||
if ($staff <= 0) out(['ok' => false, 'error' => 'staff_id requis'], 400);
|
||||
$chk = $db->query("SELECT id, first_name, last_name FROM staff WHERE id=$staff AND status=1");
|
||||
$chk = $db->query("SELECT id, first_name, last_name, email FROM staff WHERE id=$staff AND status=1");
|
||||
$s = $chk ? $chk->fetch_assoc() : null;
|
||||
if (!$s) out(['ok' => false, 'error' => 'staff inactif ou introuvable', 'staff_id' => $staff]);
|
||||
|
||||
|
|
@ -103,4 +104,77 @@ $aff = $db->affected_rows;
|
|||
$who = trim(($s['first_name'] ?? '') . ' ' . ($s['last_name'] ?? ''));
|
||||
$log = "Réassigné à #$staff ($who) via Ops." . ($assistIds ? ' Assistants : ' . implode(', ', $assistIds) . '.' : '');
|
||||
ops_log($db, $ticket, $logStaff, $log);
|
||||
out(['ok' => true, 'action' => 'reassign', 'affected' => $aff, 'staff' => $staff, 'assistants' => $assistIds]);
|
||||
|
||||
// ─── avis courriel au nouveau tech (notify=1) — MÊME contenu que ticket_view.php (résumé + lien Répondre),
|
||||
// + ligne « Adresse » fournie par Ops. Envoi via PHPMailer6 OAuth2 (creds dans ops_oauth_boot.php, hors repo). ───
|
||||
$notified = false; $notifyErr = null; $notifyTo = '';
|
||||
if (!empty($_POST['notify'])) {
|
||||
$staffEmail = trim((string)($s['email'] ?? ''));
|
||||
$notifyTo = $staffEmail;
|
||||
if ($staffEmail === '') { $notifyErr = 'staff sans courriel'; }
|
||||
else {
|
||||
$opsAddress = trim((string)($_POST['address'] ?? '')); // adresse de service propre (Ops)
|
||||
$tq = $db->query("SELECT subject, dept_id, account_id, status, due_date, due_time, bon_id, wizard_fibre FROM ticket WHERE id=$ticket LIMIT 1");
|
||||
$tr = $tq ? $tq->fetch_assoc() : [];
|
||||
$subject = (string)($tr['subject'] ?? '');
|
||||
$deptId = (int)($tr['dept_id'] ?? 0);
|
||||
$accId = (int)($tr['account_id'] ?? 0);
|
||||
$tstatus = (string)($tr['status'] ?? '');
|
||||
$dueEpoch = (int)($tr['due_date'] ?? 0);
|
||||
$dueStr = $dueEpoch > 0 ? date('d-m-Y', $dueEpoch) : 'sans date';
|
||||
$dueTime = trim((string)($tr['due_time'] ?? ''));
|
||||
$dueTimeStr = ($dueTime !== '' && $dueEpoch > 0) ? " - $dueTime" : '';
|
||||
$bonId = trim((string)($tr['bon_id'] ?? ''));
|
||||
$wizFibre = trim((string)($tr['wizard_fibre'] ?? ''));
|
||||
$dn = $db->query("SELECT name FROM ticket_dept WHERE id=$deptId LIMIT 1"); $dnr = $dn ? $dn->fetch_assoc() : null;
|
||||
$deptName = (string)($dnr['name'] ?? '');
|
||||
$cn = $db->query("SELECT first_name, last_name, company, customer_id FROM account WHERE id=$accId LIMIT 1"); $cnr = $cn ? $cn->fetch_assoc() : null;
|
||||
$clientName = $cnr ? trim(($cnr['first_name'] ?? '') . ' ' . ($cnr['last_name'] ?? '') . ' ' . ($cnr['company'] ?? '') . ' - ' . ($cnr['customer_id'] ?? '')) : '';
|
||||
// liens ONU (fibre) / bon de travail — fidèle à ticket_view.php
|
||||
$onu = '';
|
||||
if ($wizFibre !== '') { $wi = explode('|', $wizFibre); $onu = "<a href='https://store.targo.ca/targo/rep_ticket/connect_fibre.php?tt=$ticket&td=" . ($wi[0] ?? '') . "&ts=$staff&tf=" . ($wi[1] ?? '') . "&tp=" . ($wi[2] ?? '') . "'>Connecter ONU</a><br><br>"; }
|
||||
if ($deptId === 26) $onu = "<a href='https://store.targo.ca/targo/rep_ticket/change_fibre.php?tt=$ticket'>Remplacer ONU</a><br><br>";
|
||||
$bonLink = $bonId !== '' ? "Ouvrir <b>Bon de Travail</b>: https://facturation.targo.ca/facturation/accueil.php?menu=bon_travail&bon_id=$bonId <br><br>" : '';
|
||||
$addrLine = $opsAddress !== '' ? "<b>Adresse</b>: " . htmlspecialchars($opsAddress, ENT_QUOTES) . "<br/>" : '';
|
||||
$replyLink = "https://store.targo.ca/targo/reply_ticket.php?ticket=$ticket&staff=$staff";
|
||||
$subjectLine = html_entity_decode("[Ticket #$ticket] $subject [$deptName]", ENT_QUOTES);
|
||||
$body = "Ce ticket vous a été assigné.<br/><br/>"
|
||||
. "Répondre: $replyLink<br/><br/>"
|
||||
. $onu . $bonLink
|
||||
. "<b>Ticket ID</b>: <a href='https://facturation.targo.ca/facturation/accueil.php?menu=ticket_view&id=$ticket'>$ticket</a><br/>"
|
||||
. "<b>Client</b>: $clientName<br/><br/>"
|
||||
. "<b>Sujet</b>: $subject<br/>"
|
||||
. "<b>Status</b>: $tstatus<br/>"
|
||||
. "<b>Due date</b>: $dueStr $dueTimeStr<br/>"
|
||||
. $addrLine
|
||||
. "<b>Département</b>: $deptName<br/><br/>";
|
||||
try {
|
||||
// L'app legacy (vendor + PHPMailer6) est dans ./facturation quand ce script est au docroot /var/www ; sinon ici.
|
||||
$APP_DIR = is_dir(__DIR__ . '/facturation/PHPMailer6') ? __DIR__ . '/facturation' : __DIR__;
|
||||
require_once $APP_DIR . '/vendor/autoload.php'; // League\OAuth2 (Google provider) — déjà dans l'app
|
||||
require_once $APP_DIR . '/PHPMailer6/Exception.php';
|
||||
require_once $APP_DIR . '/PHPMailer6/PHPMailer.php';
|
||||
require_once $APP_DIR . '/PHPMailer6/SMTP.php';
|
||||
require_once $APP_DIR . '/PHPMailer6/OAuth.php';
|
||||
$provider = new \League\OAuth2\Client\Provider\Google(['clientId' => $clientId, 'clientSecret' => $clientSecret]); // creds depuis ops_secret.php
|
||||
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = gethostbyname('smtp.gmail.com');
|
||||
$mail->SMTPOptions = ['ssl' => ['verify_peer_name' => false]];
|
||||
$mail->Port = 587;
|
||||
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->AuthType = 'XOAUTH2';
|
||||
$mail->setOAuth(new \PHPMailer\PHPMailer\OAuth(['provider' => $provider, 'clientId' => $clientId, 'clientSecret' => $clientSecret, 'refreshToken' => $refreshToken, 'userName' => $oauthEmail]));
|
||||
$mail->setFrom('ticket@targointernet.com', 'Ticket');
|
||||
$mail->addAddress($staffEmail);
|
||||
$mail->Subject = $subjectLine;
|
||||
$mail->CharSet = \PHPMailer\PHPMailer\PHPMailer::CHARSET_UTF8;
|
||||
$mail->isHTML(true);
|
||||
$mail->Body = $body;
|
||||
$mail->send();
|
||||
$notified = true;
|
||||
} catch (\Throwable $e) { $notifyErr = $e->getMessage(); }
|
||||
}
|
||||
}
|
||||
out(['ok' => true, 'action' => 'reassign', 'affected' => $aff, 'staff' => $staff, 'assistants' => $assistIds, 'notified' => $notified, 'notify_to' => $notifyTo, 'notify_error' => $notifyErr]);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const cfg = require('./config')
|
|||
const { log, json, httpRequest } = require('./helpers')
|
||||
const { searchAddressesRpc } = require('./address-search') // recherche trigram RQA (RPC pg_trgm) — celle de l'autocomplete de dispo
|
||||
const addrdb = require('./address-db') // pool PG local (camping_registry)
|
||||
const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch')
|
||||
const fs = require('fs')
|
||||
// Pont d'ÉCRITURE legacy = endpoint PHP sur facturation.targo.ca (hérite des droits write de l'app ; aucun grant DB).
|
||||
// Token lu d'un FICHIER (/app/data/ops_legacy.token) → pas de var d'env → pas besoin de recréer le conteneur.
|
||||
|
|
@ -49,6 +50,29 @@ async function batchCloseLegacy (ticketIds, actorEmail) {
|
|||
const w = await legacyWrite({ action: 'batch_close', ticket_ids: ticketIds.join(','), actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
return (w && w.data) || { ok: false, error: 'no response' }
|
||||
}
|
||||
// Résout le staff legacy de l'acteur Ops depuis son email Authentik (0 si introuvable).
|
||||
async function resolveActorStaff (actorEmail) {
|
||||
try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) return ar[0].id } } catch (e) {}
|
||||
return 0
|
||||
}
|
||||
// RETOUR AU POOL : désassigne le Dispatch Job dans ERPNext PUIS — si le job était poussé à un tech —
|
||||
// réécrit le ticket legacy à assign_to=3301 (Tech Targo, l'inbox de dispatch). Action EXPLICITE seulement
|
||||
// (les redistributions auto vers un autre tech poussent le nouveau tech au prochain Publier, pas 3301).
|
||||
async function returnToPool (job, actorEmail) {
|
||||
if (!job) return { ok: false, error: 'job requis' }
|
||||
const djs = await erp.list('Dispatch Job', { filters: [['name', '=', job]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'status'], limit: 1 })
|
||||
const j = djs && djs[0]
|
||||
if (!j) return { ok: false, error: 'job introuvable' }
|
||||
const hadTech = !!j.assigned_tech
|
||||
const r = await erp.update('Dispatch Job', job, { assigned_tech: null, status: 'open', start_time: null }).catch(e => ({ ok: false, error: e.message }))
|
||||
let legacy = null
|
||||
if (hadTech && j.legacy_ticket_id) { // n'écrit au legacy que si le ticket avait réellement été attribué
|
||||
const actorStaff = await resolveActorStaff(actorEmail)
|
||||
const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: TARGO_TECH_STAFF_ID, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
legacy = (w && w.data) || { ok: false, error: 'no response' }
|
||||
}
|
||||
return { ok: !(r && r.ok === false), job, erp: r, legacy, returned_to_pool: !!(legacy && legacy.ok) }
|
||||
}
|
||||
|
||||
// Campings : l'adresse de service est un terrain de camping (≠ résidence du client). On force la géoloc
|
||||
// FIXE du camping (registre camping_registry). Détection robuste : le texte doit contenir « camping » OU
|
||||
|
|
@ -597,12 +621,12 @@ function pushAssignments (opts = {}) {
|
|||
_syncLock = run.then(() => {}, () => {})
|
||||
return run
|
||||
}
|
||||
async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '' } = {}) {
|
||||
async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = true } = {}) {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
let actorStaff = 0 // staff legacy du répartiteur Ops (email Authentik → staff) = auteur du log/closed_by côté legacy
|
||||
if (actorEmail) { try { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } catch (e) {} }
|
||||
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['assigned_tech', '!=', '']], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'subject', 'status'], limit: 2000 })
|
||||
if (!jobs.length) return { ok: true, dryRun, candidates: 0, pushed: 0, skipped: 0, mismatch: 0, errors: 0, samples: [] }
|
||||
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['assigned_tech', '!=', '']], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'subject', 'status', 'address'], limit: 2000 })
|
||||
if (!jobs.length) return { ok: true, dryRun, candidates: 0, pushed: 0, skipped: 0, mismatch: 0, errors: 0, notified: 0, samples: [] }
|
||||
const techIds = [...new Set(jobs.map(j => j.assigned_tech))]
|
||||
const techs = await erp.list('Dispatch Technician', { filters: [['name', 'in', techIds]], fields: ['name', 'full_name', 'email'], limit: techIds.length + 5 })
|
||||
const techByName = {}; for (const t of (techs || [])) techByName[t.name] = t
|
||||
|
|
@ -629,7 +653,7 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '' } = {}) {
|
|||
}
|
||||
return { staff: null, via: null }
|
||||
}
|
||||
let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0; const errSamples = []
|
||||
let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0, notified = 0; const errSamples = []; const notifyErrors = []
|
||||
const samples = []
|
||||
for (const j of jobs) {
|
||||
const techRow = techByName[j.assigned_tech]
|
||||
|
|
@ -641,14 +665,14 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '' } = {}) {
|
|||
if (String(tkrow.assign_to) === staffId) { skipped++; continue } // déjà assigné au bon tech → idempotent
|
||||
if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, de_staff: tkrow.assign_to, vers_staff: staffId + ' (' + (st.first_name || '') + ' ' + (st.last_name || '') + ')', via, sujet: (j.subject || '').slice(0, 34) })
|
||||
if (!dryRun) {
|
||||
const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: staffId, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: staffId, actor_staff_id: actorStaff || '', notify: notify ? 1 : '', address: j.address || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
const d = (w && w.data) || {}
|
||||
if (d.ok) pushed++
|
||||
if (d.ok) { pushed++; if (d.notified) notified++; else if (notify && d.notify_error && notifyErrors.length < 6) notifyErrors.push({ ticket: j.legacy_ticket_id, to: d.notify_to || '', error: d.notify_error }) }
|
||||
else if (d.error === 'verrouillé') { locked++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, locked_by: d.locked_by }) }
|
||||
else { errors++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, error: d.error || ('http ' + (w && w.status)) }) }
|
||||
} else pushed++
|
||||
}
|
||||
return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, locked, errors, error_samples: errSamples, samples }
|
||||
return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, locked, errors, notified, notify_errors: notifyErrors, error_samples: errSamples, samples }
|
||||
}
|
||||
|
||||
// Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed »
|
||||
|
|
@ -686,6 +710,136 @@ async function ticketThread (legacyId) {
|
|||
return { ok: true, ticket: id, subject: (trows && trows[0] && trows[0].subject) || '', status: (trows && trows[0] && trows[0].status) || '', count: messages.length, messages }
|
||||
}
|
||||
|
||||
// ── VEILLE Legacy → Ops (poll léger → SSE) ──
|
||||
// Lecture seule (sauf auto-close immédiat, idempotent) : compare l'état legacy (status/assign_to/last_update) de TOUS
|
||||
// les tickets suivis à un snapshot mémoire, et DIFFUSE les deltas sur le topic SSE 'dispatch' (l'Ops rafraîchit en direct).
|
||||
// Détecte : fermeture (→ marque le Dispatch Job Completed tout de suite), réassignation hors-Ops, changement de statut,
|
||||
// nouvelle activité (last_update). Cadence courte (≈3 min) ≠ la sync d'import (15 min). Pas de verrou (read-only legacy+ERP).
|
||||
const _watchSnap = new Map() // ticket_id → { status, assign_to, last_update }
|
||||
let _watchTimer = null
|
||||
let _lastWatch = null
|
||||
async function watchLegacy () {
|
||||
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' }
|
||||
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold', 'In Progress']]], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech', 'subject'], limit: 5000 })
|
||||
const jobByTk = new Map(); const ids = []
|
||||
for (const j of (jobs || [])) { const id = String(j.legacy_ticket_id); jobByTk.set(id, j); const n = parseInt(id, 10); if (n) ids.push(n) }
|
||||
if (!ids.length) { _lastWatch = { at: new Date().toISOString(), tracked: 0, changes: 0 }; return { ok: true, tracked: 0, changes: [] } }
|
||||
const [rows] = await p.query('SELECT id, status, assign_to, last_update FROM ticket WHERE id IN (?)', [ids])
|
||||
const seeded = _watchSnap.size > 0 // 1er passage (snapshot vide) = amorçage silencieux des DELTAS
|
||||
const changes = []; let closedNow = 0; let pulled = 0
|
||||
for (const r of (rows || [])) {
|
||||
const id = String(r.id)
|
||||
const prev = _watchSnap.get(id)
|
||||
const cur = { status: r.status, assign_to: String(r.assign_to), last_update: Number(r.last_update) || 0 }
|
||||
_watchSnap.set(id, cur)
|
||||
const j = jobByTk.get(id); if (!j) continue
|
||||
// (1) RÉCONCILIATION D'ÉTAT (à CHAQUE passage, même amorçage) : un job ENCORE dans le pool (open/On Hold, non
|
||||
// assigné côté Ops) dont le ticket legacy est PRIS par un VRAI employé (≠3301, ≠0) et pas fermé → on le RETIRE
|
||||
// du pool (status Cancelled). Couvre les tickets assignés dans Legacy AVANT le démarrage de la veille (ex. 250095).
|
||||
const at = parseInt(cur.assign_to, 10) || 0
|
||||
if (!j.assigned_tech && (j.status === 'open' || j.status === 'On Hold') && cur.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID) {
|
||||
try { await erp.update('Dispatch Job', j.name, { status: 'Cancelled' }); pulled++; changes.push({ ticket: id, job: j.name, kind: 'taken', to_staff: cur.assign_to, subject: j.subject || '' }) } catch (e) {}
|
||||
continue // sorti du pool → pas d'autre delta à émettre
|
||||
}
|
||||
// (2) DELTAS diffusés — seulement une fois le snapshot amorcé
|
||||
if (!seeded || !prev) continue
|
||||
if (prev.status !== cur.status) {
|
||||
if (cur.status === 'closed') {
|
||||
try { await erp.update('Dispatch Job', j.name, { status: 'Completed' }); closedNow++ } catch (e) {} // reflète tout de suite (idempotent)
|
||||
changes.push({ ticket: id, job: j.name, kind: 'closed', subject: j.subject || '' })
|
||||
} else changes.push({ ticket: id, job: j.name, kind: 'status', from: prev.status, to: cur.status, subject: j.subject || '' })
|
||||
} else if (prev.assign_to !== cur.assign_to) {
|
||||
changes.push({ ticket: id, job: j.name, kind: 'reassigned', to_staff: cur.assign_to, pool: cur.assign_to === String(TARGO_TECH_STAFF_ID), tech: j.assigned_tech || '', subject: j.subject || '' })
|
||||
} else if (prev.last_update !== cur.last_update) {
|
||||
changes.push({ ticket: id, job: j.name, kind: 'activity', subject: j.subject || '' })
|
||||
}
|
||||
}
|
||||
if (changes.length) sse.broadcast('dispatch', 'legacy-update', { at: new Date().toISOString(), changes })
|
||||
_lastWatch = { at: new Date().toISOString(), tracked: ids.length, seeded, closed: closedNow, pulled, changes: changes.length }
|
||||
return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, changes }
|
||||
}
|
||||
|
||||
// ── RÉCONCILIATION DES TECHNICIENS (union des 3 systèmes) — RAPPORT + apply manuel ──────────────────────────
|
||||
// 3 sources : staff legacy actif ↔ Dispatch Technician (ERPNext, TECH-<id>) ↔ groupe Authentik 'tech' (accès Ops).
|
||||
// Le rapport liste les ÉCARTS ; aucune écriture Authentik. L'apply CRÉE des fiches Dispatch Technician (ERPNext) pour
|
||||
// les staff choisis (le répartiteur coche). Matching par EMAIL pour l'accès, par id (TECH-<id>) pour la fiche.
|
||||
async function akGet (path) {
|
||||
if (!cfg.AUTHENTIK_TOKEN) return null
|
||||
const r = await httpRequest(cfg.AUTHENTIK_URL || 'https://auth.targo.ca', '/api/v3' + path, { method: 'GET', headers: { Authorization: 'Bearer ' + cfg.AUTHENTIK_TOKEN }, timeout: 12000 }).catch(() => null)
|
||||
return r && r.data
|
||||
}
|
||||
async function techGroupEmailSet () {
|
||||
const g = await akGet('/core/groups/?search=tech')
|
||||
const set = new Set()
|
||||
if (g && Array.isArray(g.results)) {
|
||||
const grp = g.results.find(x => x.name === 'tech') || g.results[0]
|
||||
for (const u of (grp && grp.users_obj) || []) { const e = (u.email || '').toLowerCase().trim(); if (e) set.add(e) }
|
||||
return { ok: true, emails: set }
|
||||
}
|
||||
return { ok: false, emails: set }
|
||||
}
|
||||
async function techSyncReport () {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const [staff] = await p.query("SELECT id, first_name, last_name, lower(trim(email)) email FROM staff WHERE status=1")
|
||||
const techs = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'status', 'email'], limit: 500 })
|
||||
const ficheStaffIds = new Set(); const ficheEmails = new Set()
|
||||
for (const t of techs) { const sid = (String(t.technician_id || t.name).match(/(\d+)$/) || [])[1]; if (sid) ficheStaffIds.add(sid); if (t.email) ficheEmails.add(String(t.email).toLowerCase().trim()) }
|
||||
const ak = await techGroupEmailSet()
|
||||
const staff_missing_fiche = []
|
||||
for (const s of (staff || [])) {
|
||||
if (ficheStaffIds.has(String(s.id))) continue
|
||||
if (s.email && ficheEmails.has(s.email)) continue // déjà une fiche (matchée par courriel)
|
||||
staff_missing_fiche.push({ staff_id: s.id, name: [s.first_name, s.last_name].filter(Boolean).join(' ') || ('staff ' + s.id), email: s.email || '', in_tech_group: !!(s.email && ak.emails.has(s.email)) })
|
||||
}
|
||||
const fiche_no_access = ak.ok ? techs.filter(t => { const e = (t.email || '').toLowerCase().trim(); return e && !ak.emails.has(e) }).map(t => ({ technician_id: t.technician_id, name: t.full_name, email: t.email })) : []
|
||||
const fiche_no_email = techs.filter(t => !t.email).map(t => ({ technician_id: t.technician_id, name: t.full_name }))
|
||||
const access_no_fiche = ak.ok ? [...ak.emails].filter(e => !ficheEmails.has(e)).sort() : []
|
||||
return {
|
||||
ok: true, authentik: ak.ok,
|
||||
counts: { staff_active: (staff || []).length, fiches: techs.length, tech_group: ak.emails.size, missing_fiche: staff_missing_fiche.length, no_access: fiche_no_access.length, access_no_fiche: access_no_fiche.length },
|
||||
staff_missing_fiche: staff_missing_fiche.sort((a, b) => b.staff_id - a.staff_id),
|
||||
fiche_no_access, fiche_no_email, access_no_fiche,
|
||||
}
|
||||
}
|
||||
async function techSyncApply (staffIds) {
|
||||
const ids = [...new Set((staffIds || []).map(x => parseInt(x, 10)).filter(Boolean))]
|
||||
if (!ids.length) return { ok: true, created: 0, results: [] }
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const [rows] = await p.query('SELECT id, first_name, last_name, email FROM staff WHERE id IN (?) AND status=1', [ids])
|
||||
const byId = new Map((rows || []).map(r => [String(r.id), r]))
|
||||
let created = 0; const results = []
|
||||
for (const id of ids) { // SÉQUENTIEL (frappe_pg)
|
||||
const s = byId.get(String(id)); if (!s) { results.push({ staff_id: id, ok: false, error: 'staff inactif/introuvable' }); continue }
|
||||
const tid = 'TECH-' + id
|
||||
const ex = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', tid]], fields: ['name'], limit: 1 })
|
||||
if (ex && ex.length) { results.push({ staff_id: id, ok: true, skipped: 'existe déjà', technician_id: tid }); continue }
|
||||
const body = { technician_id: tid, full_name: [s.first_name, s.last_name].filter(Boolean).join(' ') || tid, resource_type: 'human', status: 'Disponible', efficiency: 1 }
|
||||
if (s.email) body.email = s.email
|
||||
try { await erp.create('Dispatch Technician', body); created++; results.push({ staff_id: id, ok: true, technician_id: tid }) } catch (e) { results.push({ staff_id: id, ok: false, error: String(e.message || e) }) }
|
||||
}
|
||||
return { ok: true, created, results }
|
||||
}
|
||||
|
||||
// MINEUR de durées (baseline d'apprentissage SANS app) : approxime le temps sur-site par la fenêtre des réponses STAFF
|
||||
// d'un même ticket le même jour (< 8 h). Bruité à l'unité mais l'agrégat (médiane par dept/type) donne un point de départ.
|
||||
async function mineDurations () {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const [rows] = await p.query(
|
||||
`SELECT t.dept_id, (MAX(m.date_orig) - MIN(m.date_orig)) AS span
|
||||
FROM ticket t JOIN ticket_msg m ON m.ticket_id = t.id
|
||||
WHERE t.status='closed' AND t.date_closed > UNIX_TIMESTAMP() - 180*86400 AND m.staff_id > 0
|
||||
GROUP BY t.id, t.dept_id HAVING span > 120 AND span < 8*3600`)
|
||||
const byDept = {}
|
||||
for (const r of (rows || [])) { (byDept[r.dept_id] || (byDept[r.dept_id] = [])).push(Number(r.span) / 60) }
|
||||
const out = []
|
||||
for (const dept in byDept) {
|
||||
const arr = byDept[dept].sort((a, b) => a - b); if (arr.length < 3) continue
|
||||
out.push({ dept_id: Number(dept), job_type: jobType(Number(dept)), n: arr.length, median_min: Math.round(arr[Math.floor(arr.length / 2)]), p75_min: Math.round(arr[Math.floor(arr.length * 0.75)]) })
|
||||
}
|
||||
out.sort((a, b) => b.n - a.n)
|
||||
return { ok: true, note: 'Proxy bruité (fenêtre réponses staff même jour <8h, 180j). Baseline en attendant la capture GPS/app.', by_dept: out.slice(0, 25) }
|
||||
}
|
||||
|
||||
// ── Récurrence (setInterval) ──
|
||||
let _timer = null
|
||||
let _lastRun = null // heartbeat : dernier passage réussi (pour /status + Uptime-Kuma)
|
||||
|
|
@ -700,8 +854,14 @@ function startSync () {
|
|||
setTimeout(tick, 90 * 1000)
|
||||
_timer = setInterval(tick, minutes * 60 * 1000)
|
||||
log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`)
|
||||
// VEILLE temps-réel (poll léger → SSE) : cadence courte, indépendante de l'import.
|
||||
const watchMin = Number(process.env.LEGACY_DISPATCH_WATCH_MIN) || 3
|
||||
const wtick = () => watchLegacy().catch(e => log('legacy-watch error:', e.message))
|
||||
setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot
|
||||
_watchTimer = setInterval(wtick, watchMin * 60 * 1000)
|
||||
log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`)
|
||||
}
|
||||
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } }
|
||||
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } }
|
||||
|
||||
async function handle (req, res, method, path) {
|
||||
try {
|
||||
|
|
@ -714,7 +874,10 @@ async function handle (req, res, method, path) {
|
|||
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone
|
||||
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'POST') return json(res, 200, await fixGeocoding({ dryRun: false })) // applique
|
||||
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'GET') return json(res, 200, await pushAssignments({ dryRun: true })) // APERÇU write-back tech → legacy (0 écriture)
|
||||
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '' })) // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik
|
||||
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') { // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik ; notify=0 coupe l'avis courriel
|
||||
const notify = new URL(req.url, 'http://localhost').searchParams.get('notify') !== '0'
|
||||
return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '', notify }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/close-ticket' && method === 'POST') { // ferme un ticket legacy + marque le Dispatch Job Completed
|
||||
const id = new URL(req.url, 'http://localhost').searchParams.get('ticket'); if (!id) return json(res, 400, { ok: false, error: 'ticket requis' })
|
||||
const r = await closeTicketLegacy(id, req.headers['x-authentik-email'] || '')
|
||||
|
|
@ -728,8 +891,19 @@ async function handle (req, res, method, path) {
|
|||
if (r && r.ok && Array.isArray(r.results)) { for (const it of r.results) { if (it.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(it.t)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} } } }
|
||||
return json(res, 200, r)
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/return-to-pool' && method === 'POST') { // désassigne (ERPNext) + retour pool 3301 (legacy) — action EXPLICITE
|
||||
const job = new URL(req.url, 'http://localhost').searchParams.get('job'); if (!job) return json(res, 400, { ok: false, error: 'job requis' })
|
||||
return json(res, 200, await returnToPool(job, req.headers['x-authentik-email'] || ''))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'GET') return json(res, 200, await purgeStaleOrphans({ dryRun: true })) // aperçu purge orphelins TT- périmés
|
||||
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'POST') return json(res, 200, await purgeStaleOrphans({ dryRun: false })) // supprime
|
||||
if (path === '/dispatch/legacy-sync/watch' && method === 'GET') return json(res, 200, await watchLegacy()) // poll manuel + deltas (diffuse aussi sur SSE 'dispatch')
|
||||
if (path === '/dispatch/legacy-sync/tech-sync' && method === 'GET') return json(res, 200, await techSyncReport()) // rapport réconciliation techs (3 systèmes)
|
||||
if (path === '/dispatch/legacy-sync/mine-durations' && method === 'GET') return json(res, 200, await mineDurations()) // baseline durées apprises (proxy réponses staff)
|
||||
if (path === '/dispatch/legacy-sync/tech-sync/apply' && method === 'POST') { // crée les fiches Dispatch Technician choisies (ERPNext only)
|
||||
const ids = (new URL(req.url, 'http://localhost').searchParams.get('ids') || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
return json(res, 200, await techSyncApply(ids))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
|
||||
if (path === '/dispatch/legacy-sync/close-resolved' && method === 'POST') return json(res, 200, await closeResolved())
|
||||
if (path === '/dispatch/legacy-sync/ticket-thread' && method === 'GET') { const id = new URL(req.url, 'http://localhost').searchParams.get('id'); return json(res, 200, await ticketThread(id)) }
|
||||
|
|
@ -744,4 +918,4 @@ async function handle (req, res, method, path) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests
|
||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests
|
||||
|
|
|
|||
|
|
@ -258,8 +258,66 @@ async function coverage (start, days) {
|
|||
// compétence requise, est disponible (trous dans son shift moins les jobs déjà
|
||||
// pointés). Sert aux 2 canaux : on propose au client, ou on valide son choix.
|
||||
function timeToH (t) { if (!t) return 0; const [h, m] = String(t).split(':').map(Number); return (h || 0) + (m || 0) / 60 }
|
||||
function nowFrappe () { return new Date().toLocaleString('sv-SE', { timeZone: 'America/Toronto' }).replace('T', ' ') } // "YYYY-MM-DD HH:MM:SS"
|
||||
function minutesBetween (a, b) { if (!a || !b) return null; const ms = Date.parse(String(b).replace(' ', 'T')) - Date.parse(String(a).replace(' ', 'T')); return isFinite(ms) ? Math.max(0, Math.round(ms / 60000)) : null }
|
||||
function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
|
||||
|
||||
// ── DURÉE D'UNE JOB = modèle ADDITIF par caractéristiques (hors transport) ───────────────────────────────
|
||||
// Base (1) + Σ caractéristiques cochées (×qté), × vitesse tech. Table ÉDITABLE (tableur inline Ops), persistée en JSON.
|
||||
// `minutes` = seed (réglé à la main, inspiré du tableur dispatch) ; `learned_min`+`samples` = calibration future par
|
||||
// régression sur les durées RÉELLES (geofence/checkpoints) — « triangulation » des temps par caractéristique.
|
||||
const JOBCHAR_PATH = '/app/data/job-characteristics.json'
|
||||
const JOBCHAR_SEED = { items: [
|
||||
{ id: 'install_resid', cat: 'base', label: 'Installation fibre résidentielle', minutes: 120, per_qty: false, keywords: 'installation,raccordement' },
|
||||
{ id: 'install_comm', cat: 'base', label: 'Installation commerciale', minutes: 240, per_qty: false, keywords: 'commercial' },
|
||||
{ id: 'predrop', cat: 'base', label: 'Pré-drop (pose)', minutes: 120, per_qty: false, keywords: 'pre-drop,predrop,pré-drop' },
|
||||
{ id: 'repar_fibre', cat: 'base', label: 'Réparation / bris de fibre', minutes: 105, per_qty: false, keywords: 'réparation,reparation,bris,fibre' },
|
||||
{ id: 'depan_wifi', cat: 'base', label: 'Dépannage Wifi / réseau', minutes: 90, per_qty: false, keywords: 'wifi,sans-fil,internet' },
|
||||
{ id: 'changer_secteur', cat: 'base', label: 'Changer de secteur', minutes: 75, per_qty: false, keywords: 'secteur' },
|
||||
{ id: 'retrait', cat: 'base', label: 'Retrait / désinstallation', minutes: 60, per_qty: false, keywords: 'retrait,désinstall,desinstall' },
|
||||
{ id: 'chgmt_equip', cat: 'base', label: "Changement d'équipement (ONU/modem/Hx)", minutes: 45, per_qty: false, keywords: 'changement,onu,modem,équipement,equipement,hx' },
|
||||
{ id: 'migration_tv', cat: 'base', label: 'Migration TV / boîtier', minutes: 45, per_qty: false, keywords: 'migration,tv,boîtier,boitier' },
|
||||
{ id: 'ata', cat: 'base', label: 'Activation téléphonie (ATA)', minutes: 45, per_qty: false, keywords: 'ata,téléphonie,telephonie' },
|
||||
{ id: 'enfoui', cat: 'addon', label: 'Drop enfoui (au lieu d’aérien)', minutes: 75, per_qty: false, keywords: 'enfoui,enterré,enterre,souterrain' },
|
||||
{ id: 'echelle', cat: 'addon', label: 'Aérien avec échelle / poteau', minutes: 30, per_qty: false, keywords: 'aérien,aerien,poteau,échelle,echelle' },
|
||||
{ id: 'dist_poteau', cat: 'addon', label: 'Distance poteau → maison > 60 m', minutes: 30, per_qty: false, keywords: '' },
|
||||
{ id: 'boitier_tv', cat: 'addon', label: 'Boîtier TV / STB supplémentaire', minutes: 30, per_qty: true, keywords: 'stb,boîtier tv,boitier tv' },
|
||||
{ id: 'mesh', cat: 'addon', label: "Point d'accès mesh supplémentaire", minutes: 20, per_qty: true, keywords: 'mesh,borne' },
|
||||
{ id: 'tel_ata', cat: 'addon', label: 'Ligne téléphonie / ATA ajoutée', minutes: 30, per_qty: true, keywords: '' },
|
||||
{ id: 'epissure', cat: 'addon', label: 'Épissure / fusion (par connecteur)', minutes: 20, per_qty: true, keywords: 'épissure,epissure,fusion' },
|
||||
{ id: 'wifi_complexe', cat: 'addon', label: 'Config Wifi complexe (multi-SSID/VLAN)', minutes: 30, per_qty: false, keywords: '' },
|
||||
{ id: 'tel_complexe', cat: 'addon', label: 'Téléphonie complexe (PBX/multi-lignes)', minutes: 30, per_qty: false, keywords: 'pbx' },
|
||||
{ id: 'test_otdr', cat: 'addon', label: 'Test / certification (OTDR, photos)', minutes: 20, per_qty: false, keywords: 'otdr,certification' },
|
||||
{ id: 'acces_difficile', cat: 'addon', label: 'Accès difficile (hauteur, vide sanitaire)', minutes: 30, per_qty: false, keywords: '' },
|
||||
{ id: 'tampon_client', cat: 'modifier', label: 'Tampon coordination client', minutes: 15, per_qty: false, keywords: '' },
|
||||
{ id: 'junior_install', cat: 'modifier', label: 'Tech junior sur install (pénalité)', minutes: 90, per_qty: false, keywords: '' },
|
||||
] }
|
||||
function readJobChar () { try { return JSON.parse(require('fs').readFileSync(JOBCHAR_PATH, 'utf8')) } catch (e) { return JSON.parse(JSON.stringify(JOBCHAR_SEED)) } }
|
||||
function writeJobChar (items) { try { require('fs').writeFileSync(JOBCHAR_PATH, JSON.stringify({ items, updated: nowFrappe() }, null, 2)); return true } catch (e) { return false } }
|
||||
// Durée estimée (minutes, hors transport) à partir des caractéristiques cochées : picks = [{id, qty}].
|
||||
function estimateMinutes (picks, items) {
|
||||
const by = {}; for (const i of (items || readJobChar().items)) by[i.id] = i
|
||||
let m = 0; for (const p of (picks || [])) { const it = by[p.id]; if (!it) continue; const mn = (it.learned_min != null ? it.learned_min : it.minutes) || 0; m += mn * (it.per_qty ? (Number(p.qty) || 1) : 1) }
|
||||
return Math.round(m)
|
||||
}
|
||||
// Auto-détection DÉTERMINISTE (mots-clés / regex, AUCUNE IA) des caractéristiques d'un job depuis son texte.
|
||||
function detectCharacteristics (job, items) {
|
||||
const strip = s => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
|
||||
const text = strip([job.subject, job.legacy_detail, job.legacy_dept, job.job_type, job.service_type].join(' '))
|
||||
const hit = kw => String(kw || '').split(',').map(x => strip(x).trim()).filter(Boolean).some(k => new RegExp('\\b' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').test(text)) // limites de mots → pas de faux positif « ap » dans « après »
|
||||
const bases = items.filter(i => i.cat === 'base')
|
||||
let base = bases.find(b => hit(b.keywords))
|
||||
if (!base) { const jt = strip(job.job_type); base = bases.find(b => (jt.includes('install') && b.id === 'install_resid') || (jt.includes('repar') && b.id === 'repar_fibre') || (jt.includes('retrait') && b.id === 'retrait')) }
|
||||
const picks = base ? [{ id: base.id, qty: 1 }] : []
|
||||
for (const a of items.filter(i => i.cat === 'addon')) if (hit(a.keywords)) picks.push({ id: a.id, qty: 1 })
|
||||
return picks
|
||||
}
|
||||
function estimateForJob (job, items) {
|
||||
items = items || readJobChar().items
|
||||
const picks = detectCharacteristics(job, items); const by = {}; for (const i of items) by[i.id] = i
|
||||
return { minutes: estimateMinutes(picks, items), picks, labels: picks.map(p => (by[p.id] || {}).label).filter(Boolean) }
|
||||
}
|
||||
|
||||
// ── #56 Politique de créneaux offerts + holds temporaires ────────────────────
|
||||
// Persistée dans le même fichier que la politique de reprise (sous-objet `booking`),
|
||||
// éditée via /roster/policy (lib/roster-assistant.js). Appliquée ici à TOUTE source
|
||||
|
|
@ -498,6 +556,109 @@ async function handlePublicBooking (req, res, method, path, url) {
|
|||
return json(res, 404, { error: 'not found' })
|
||||
}
|
||||
|
||||
// ── APP TECHNICIEN : capture passive du temps via « checkpoints » (GPS geofence / scan / etc.) ──────────────
|
||||
// Token signé par job (HMAC, stateless, sans login ni champ DB). Voir docs/field-tech-app.md.
|
||||
const FIELD_SECRET = cfg.INTERNAL_TOKEN || cfg.ERP_SERVICE_TOKEN || 'targo-field-v1'
|
||||
const ON_SITE_M = Number(process.env.FIELD_ONSITE_RADIUS_M) || 250 // rayon « sur place » autour de l'adresse
|
||||
function fieldSign (name) { const h = crypto.createHmac('sha256', FIELD_SECRET).update(String(name)).digest('hex').slice(0, 12); return Buffer.from(String(name)).toString('base64url') + '.' + h }
|
||||
function fieldVerify (token) { try { const [b, h] = String(token || '').split('.'); if (!b || !h) return null; const name = Buffer.from(b, 'base64url').toString('utf8'); const exp = crypto.createHmac('sha256', FIELD_SECRET).update(name).digest('hex').slice(0, 12); return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp)) ? name : null } catch (e) { return null } }
|
||||
function haversineM (a1, o1, a2, o2) { const R = 6371000, t = Math.PI / 180; const dA = (a2 - a1) * t, dO = (o2 - o1) * t; const x = Math.sin(dA / 2) ** 2 + Math.cos(a1 * t) * Math.cos(a2 * t) * Math.sin(dO / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))) }
|
||||
// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Token Mapbox public (pk) pour la carte de l'app.
|
||||
const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
|
||||
function techFieldSign (name) { return fieldSign('T:' + name) }
|
||||
function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null }
|
||||
function serveFieldApp (res) {
|
||||
try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__MAPBOX_TOKEN__').join(FIELD_MAPBOX).split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') }
|
||||
}
|
||||
function savePhoto (jobName, dataUrl) {
|
||||
const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null
|
||||
const buf = Buffer.from(m[2], 'base64'); if (!buf.length || buf.length > 6 * 1024 * 1024) return null
|
||||
const fs = require('fs'); const dir = '/app/uploads/field'; try { fs.mkdirSync(dir, { recursive: true }) } catch (e) {}
|
||||
const fn = 'f_' + String(jobName).replace(/[^a-zA-Z0-9_-]/g, '') + '_' + Date.now() + '.' + (m[1] === 'png' ? 'png' : 'jpg')
|
||||
try { fs.writeFileSync(dir + '/' + fn, buf); return fn } catch (e) { return null }
|
||||
}
|
||||
async function handleFieldTech (req, res, method, path, url) {
|
||||
const token = url.searchParams.get('t') || url.searchParams.get('token') || ''
|
||||
if (path === '/field/job' && method === 'GET') {
|
||||
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'actual_start', 'actual_end', 'status'], limit: 1 })
|
||||
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
|
||||
return json(res, 200, { ok: true, job: { ...j, actual_minutes: minutesBetween(j.actual_start, j.actual_end) } })
|
||||
}
|
||||
if (path === '/field/checkpoint' && method === 'POST') {
|
||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'latitude', 'longitude', 'actual_start', 'actual_end', 'completion_notes', 'status'], limit: 1 })
|
||||
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
|
||||
const ts = nowFrappe()
|
||||
const lat = Number(b.lat), lon = Number(b.lon); const hasGps = isFinite(lat) && isFinite(lon)
|
||||
let dist = null
|
||||
if (hasGps && j.latitude != null && j.longitude != null && (Math.abs(+j.latitude) > 0.01)) dist = haversineM(+j.latitude, +j.longitude, lat, lon)
|
||||
const onSite = (b.type === 'geo_exit') || dist == null || dist <= ON_SITE_M // hors-zone ignoré pour la durée, gardé en note
|
||||
const patch = {}
|
||||
if (onSite) { if (!j.actual_start) patch.actual_start = ts; patch.actual_end = ts; if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress' }
|
||||
const note = `• ${ts} ${b.type || 'checkpoint'}${b.ref ? ' ' + String(b.ref).slice(0, 40) : ''}${hasGps ? ` · GPS ${lat.toFixed(5)},${lon.toFixed(5)}${b.acc ? ' ±' + Math.round(b.acc) + 'm' : ''}${dist != null ? ` · à ${dist}m` : ''}` : ''}${onSite ? '' : ' · HORS ZONE (ignoré durée)'}`
|
||||
patch.completion_notes = ((j.completion_notes || '') + '\n' + note).slice(-4000)
|
||||
const up = await retryWrite(() => erp.update('Dispatch Job', name, patch))
|
||||
const startTs = j.actual_start || (onSite ? ts : null)
|
||||
return json(res, up.ok ? 200 : 500, { ok: !!up.ok, on_site: onSite, distance_m: dist, minutes: minutesBetween(startTs, onSite ? ts : j.actual_end), actual_start: patch.actual_start || j.actual_start || '', actual_end: patch.actual_end || j.actual_end || '' })
|
||||
}
|
||||
if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
|
||||
if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
|
||||
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const dates = rangeDates(todayET(), 2) // aujourd'hui + demain
|
||||
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'start_time', 'status', 'actual_start', 'actual_end', 'legacy_ticket_id', 'legacy_activation_url'], orderBy: 'scheduled_date asc, start_time asc', limit: 200 })
|
||||
const staffId = (String(tech).match(/(\d+)$/) || [])[1] || ''
|
||||
const jobs = (rows || []).map(j => ({ name: j.name, subject: j.subject, customer: j.customer_name || '', address: j.address || '', lat: j.latitude, lon: j.longitude, date: j.scheduled_date, start: j.start_time ? String(j.start_time).slice(0, 5) : '', status: j.status, started: !!j.actual_start, ended: !!j.actual_end, minutes: minutesBetween(j.actual_start, j.actual_end), token: fieldSign(j.name), reply: j.legacy_ticket_id ? `https://store.targo.ca/targo/reply_ticket.php?ticket=${j.legacy_ticket_id}&staff=${staffId}` : '', activation: j.legacy_activation_url || '' }))
|
||||
return json(res, 200, { ok: true, tech, jobs })
|
||||
}
|
||||
if (path === '/field/photo' && method === 'POST') { // photo (base64) → /app/uploads/field + note sur le job
|
||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const fn = savePhoto(name, b.image); if (!fn) return json(res, 400, { ok: false, error: 'image invalide' })
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes'], limit: 1 }); const j = r && r[0]
|
||||
const note = `\n• ${nowFrappe()} 📷 photo ${fn}${b.note ? ' — ' + String(b.note).slice(0, 60) : ''}`
|
||||
await retryWrite(() => erp.update('Dispatch Job', name, { completion_notes: (((j && j.completion_notes) || '') + note).slice(-4000) }))
|
||||
return json(res, 200, { ok: true, file: fn })
|
||||
}
|
||||
if (path === '/field/device' && method === 'POST') { // appareil scané (série/MAC) → checkpoint présence + note (GenieACS à brancher)
|
||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const serial = String(b.serial || '').slice(0, 60), mac = String(b.mac || '').slice(0, 40), kind = String(b.kind || 'appareil').slice(0, 30)
|
||||
if (!serial && !mac) return json(res, 400, { ok: false, error: 'série ou MAC requis' })
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes', 'actual_start', 'status'], limit: 1 }); const j = r && r[0]
|
||||
const ts = nowFrappe(); const patch = { completion_notes: (((j && j.completion_notes) || '') + `\n• ${ts} 📦 ${kind}${serial ? ' SN ' + serial : ''}${mac ? ' MAC ' + mac : ''}`).slice(-4000), actual_end: ts }
|
||||
if (j && !j.actual_start) patch.actual_start = ts // un scan sur place = preuve de présence (démarre le chrono)
|
||||
if (j && (j.status === 'open' || j.status === 'assigned')) patch.status = 'In Progress'
|
||||
await retryWrite(() => erp.update('Dispatch Job', name, patch))
|
||||
return json(res, 200, { ok: true, serial, mac, kind, genieacs: 'à brancher par MAC' })
|
||||
}
|
||||
if (path === '/field/ts' && method === 'POST') { // webhook geofence natif Transistorsoft (autoSync) → checkpoints. Auto-authentifié : identifier = token signé du job.
|
||||
const b = await parseBody(req)
|
||||
const locs = Array.isArray(b.location) ? b.location : (b.location ? [b.location] : (Array.isArray(b) ? b : []))
|
||||
let applied = 0
|
||||
for (const L of locs) {
|
||||
const g = L && L.geofence; if (!g || !g.identifier) continue
|
||||
const name = fieldVerify(g.identifier); if (!name) continue
|
||||
const type = String(g.action).toUpperCase() === 'EXIT' ? 'geo_exit' : 'geo_enter'
|
||||
const c = (L.coords) || {}; const ts = nowFrappe()
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'actual_start', 'completion_notes', 'status'], limit: 1 }); const j = r && r[0]; if (!j) continue
|
||||
const patch = { actual_end: ts, completion_notes: (((j.completion_notes) || '') + `\n• ${ts} ${type} (geofence natif)${c.latitude ? ` · GPS ${(+c.latitude).toFixed(5)},${(+c.longitude).toFixed(5)}` : ''}`).slice(-4000) }
|
||||
if (type === 'geo_enter' && !j.actual_start) patch.actual_start = ts
|
||||
if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress'
|
||||
await retryWrite(() => erp.update('Dispatch Job', name, patch)).catch(() => {}); applied++
|
||||
}
|
||||
return json(res, 200, { ok: true, applied })
|
||||
}
|
||||
if (path === '/field/vision' && method === 'POST') { // photo d'étiquette → proxy IA Gemini → {brand,model,serial_number,mac_address}
|
||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
if (!b.image) return json(res, 400, { ok: false, error: 'image requise' })
|
||||
try { const eq = await require('./vision').extractEquipment(String(b.image).replace(/^data:image\/[^;]+;base64,/, '')); return json(res, 200, { ok: true, ...eq }) }
|
||||
catch (e) { return json(res, 500, { ok: false, error: String(e.message || e) }) }
|
||||
}
|
||||
return json(res, 404, { ok: false, error: 'not found' })
|
||||
}
|
||||
// Génère le lien terrain d'un job (pour SMS/app/QR) — exposé via une route interne.
|
||||
function fieldLink (name) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + fieldSign(name) }
|
||||
function techFieldLink (techName) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + techFieldSign(techName) }
|
||||
|
||||
// Stats par jour : effectif (techs distincts), heures TRAVAILLÉES, tickets dispatch.
|
||||
// La garde (on_call) = mise en disponibilité → exclue des heures travaillées.
|
||||
async function statsByDay (start, days) {
|
||||
|
|
@ -525,20 +686,23 @@ async function occupancyByTechDay (start, days) {
|
|||
const dates = rangeDates(start, days)
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
|
||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location'], limit: 5000,
|
||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location', 'actual_start', 'actual_end'], limit: 5000,
|
||||
})
|
||||
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
|
||||
const occChars = readJobChar().items // table additive (1 lecture) → durée EFFECTIVE estimée par job
|
||||
const m = {}
|
||||
for (const j of jobs) {
|
||||
if (!j.assigned_tech || !j.scheduled_date) continue
|
||||
const k = j.assigned_tech + '|' + j.scheduled_date
|
||||
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
|
||||
const dur = Number(j.duration_h) || 0
|
||||
const estMin = estimateForJob(j, occChars).minutes
|
||||
const dur = estMin ? estMin / 60 : (Number(j.duration_h) || 0) // estimation additive (sinon duration_h)
|
||||
o.h += dur
|
||||
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills)
|
||||
const s = j.start_time ? timeToH(j.start_time) : null
|
||||
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400) })
|
||||
const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
|
||||
}
|
||||
// ordre = route_order manuel s'il existe, sinon priorité puis heure
|
||||
for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
|
||||
|
|
@ -567,6 +731,56 @@ async function resolveTechName (techId) {
|
|||
return f.length ? f[0].name : null
|
||||
}
|
||||
|
||||
// ── CAPACITÉ par jour, découpée en segments AM / PM / Soir ──────────────────
|
||||
// Capacité d'un segment = Σ (recouvrement du quart de chaque tech assigné ce jour-là avec la plage du segment).
|
||||
// Utilisé = Σ (recouvrement des jobs PLACÉS (start_time) ce jour-là avec le segment).
|
||||
// Libre = capacité − utilisé (heures-tech restantes). due_h = Σ durée estimée des jobs DUS ce jour (placés ou non) ;
|
||||
// unplaced_h = part des dus non encore placés. overbooked = due_h > capacité totale du jour.
|
||||
const DAY_SEGMENTS = [
|
||||
{ key: 'am', label: 'AM', from: 8, to: 12 },
|
||||
{ key: 'pm', label: 'PM', from: 12, to: 17 },
|
||||
{ key: 'soir', label: 'Soir', from: 17, to: 21 },
|
||||
]
|
||||
const segOverlap = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
|
||||
const r1 = (x) => Math.round(x * 10) / 10
|
||||
async function capacityByDay (start, days) {
|
||||
const dates = rangeDates(start, days)
|
||||
const templates = await fetchTemplates()
|
||||
const tpl = {}; for (const t of templates) tpl[t.name] = { s: timeToH(t.start_time), e: timeToH(t.end_time) }
|
||||
const asgs = await fetchAssignments(start, days) // Proposé + Publié = présence planifiée du tech
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]],
|
||||
fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'assigned_tech'], limit: 5000,
|
||||
})
|
||||
const out = {}
|
||||
for (const d of dates) out[d] = { date: d, segments: DAY_SEGMENTS.map(s => ({ key: s.key, label: s.label, cap: 0, used: 0, free: 0 })), cap_h: 0, used_h: 0, free_h: 0, due_h: 0, jobs_due: 0, unplaced_h: 0, techs: 0 }
|
||||
const techSeen = {}
|
||||
for (const a of asgs) {
|
||||
const o = out[a.date]; if (!o) continue
|
||||
const w = tpl[a.shift]; if (!w || !(w.e > w.s)) continue
|
||||
DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].cap += segOverlap(w.s, w.e, seg.from, seg.to) })
|
||||
const tk = a.date + '|' + a.tech; if (!techSeen[tk]) { techSeen[tk] = 1; o.techs++ }
|
||||
}
|
||||
for (const j of jobs) {
|
||||
const o = out[j.scheduled_date]; if (!o) continue
|
||||
const dur = Number(j.duration_h) || 0
|
||||
o.due_h += dur; o.jobs_due++
|
||||
const sh = j.start_time ? timeToH(j.start_time) : null
|
||||
if (sh != null && dur > 0) DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].used += segOverlap(sh, sh + dur, seg.from, seg.to) })
|
||||
else if (!j.assigned_tech) o.unplaced_h += dur // dû mais pas encore placé sur l'horaire
|
||||
}
|
||||
for (const d of dates) {
|
||||
const o = out[d]
|
||||
o.segments.forEach(s => { s.cap = r1(s.cap); s.used = r1(s.used); s.free = r1(s.cap - s.used) })
|
||||
o.cap_h = r1(o.segments.reduce((x, s) => x + s.cap, 0))
|
||||
o.used_h = r1(o.segments.reduce((x, s) => x + s.used, 0))
|
||||
o.free_h = r1(o.cap_h - o.used_h)
|
||||
o.due_h = r1(o.due_h); o.unplaced_h = r1(o.unplaced_h)
|
||||
o.overbooked = o.cap_h > 0 && o.due_h > o.cap_h
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function handle (req, res, method, path, url) {
|
||||
const qs = url.searchParams
|
||||
const start = qs.get('start')
|
||||
|
|
@ -609,6 +823,22 @@ async function handle (req, res, method, path, url) {
|
|||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { coverage: await coverage(start, days) })
|
||||
}
|
||||
if (path === '/roster/capacity' && method === 'GET') { // dispo restante par jour × segment (AM/PM/Soir)
|
||||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { capacity: await capacityByDay(start, days), segments: DAY_SEGMENTS })
|
||||
}
|
||||
if (path === '/roster/job-characteristics' && method === 'GET') return json(res, 200, { ok: true, ...readJobChar() }) // table additive de durées (éditable)
|
||||
if (path === '/roster/job-characteristics' && method === 'POST') { // sauvegarde du tableur inline
|
||||
const b = await parseBody(req); if (!Array.isArray(b.items)) return json(res, 400, { ok: false, error: 'items requis' })
|
||||
const clean = b.items.filter(i => i && i.id && i.label).map(i => ({ id: String(i.id), cat: ['base', 'addon', 'modifier'].includes(i.cat) ? i.cat : 'addon', label: String(i.label).slice(0, 80), minutes: Math.max(0, Math.round(Number(i.minutes) || 0)), per_qty: !!i.per_qty, keywords: String(i.keywords || '').slice(0, 160), learned_min: i.learned_min != null ? Math.round(Number(i.learned_min)) : null, samples: Number(i.samples) || 0 }))
|
||||
const ok = writeJobChar(clean); return json(res, ok ? 200 : 500, { ok, count: clean.length })
|
||||
}
|
||||
if (path === '/roster/tech-link' && method === 'GET') { // lien app PWA par tech (à copier / SMS) + son téléphone
|
||||
const tech = qs.get('tech'); if (!tech) return json(res, 400, { ok: false, error: 'tech requis' })
|
||||
const rows = await erp.list('Dispatch Technician', { filters: [['name', '=', tech]], fields: ['name', 'full_name', 'phone'], limit: 1 })
|
||||
const t = rows && rows[0]
|
||||
return json(res, 200, { ok: true, tech, name: (t && t.full_name) || tech, phone: (t && t.phone) || '', url: techFieldLink(tech) })
|
||||
}
|
||||
if (path === '/roster/stats' && method === 'GET') {
|
||||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { stats: await statsByDay(start, days) })
|
||||
|
|
@ -752,7 +982,12 @@ async function handle (req, res, method, path, url) {
|
|||
if (path === '/roster/unassigned-jobs' && method === 'GET') {
|
||||
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
|
||||
const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés
|
||||
for (const j of jobs) j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
|
||||
const chars = readJobChar().items // table additive (1 lecture)
|
||||
for (const j of jobs) {
|
||||
j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
|
||||
const est = estimateForJob(j, chars) // auto-détection DÉTERMINISTE (mots-clés) → durée additive estimée
|
||||
j.est_min = est.minutes; j.est_labels = est.labels
|
||||
}
|
||||
await attachLocations(jobs)
|
||||
return json(res, 200, { jobs })
|
||||
}
|
||||
|
|
@ -797,6 +1032,32 @@ async function handle (req, res, method, path, url) {
|
|||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
|
||||
}
|
||||
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis via le sélecteur Mapbox Ops.
|
||||
if (path === '/roster/job/set-location' && method === 'POST') {
|
||||
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
const lat = Number(b.lat), lon = Number(b.lon)
|
||||
if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) return json(res, 400, { error: 'lat/lon invalides' })
|
||||
const patch = { latitude: lat, longitude: lon }
|
||||
if (b.address != null && String(b.address).trim() !== '') patch.address = String(b.address).slice(0, 140)
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, latitude: lat, longitude: lon })
|
||||
}
|
||||
// CHRONO (boucle de capture) : début/fin réels → durée réelle (carburant de l'apprentissage des durées par type×tech).
|
||||
if (path === '/roster/job/start' && method === 'POST') {
|
||||
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
const now = nowFrappe()
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { actual_start: now, actual_end: '', status: 'In Progress' }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_start: now })
|
||||
}
|
||||
if (path === '/roster/job/finish' && method === 'POST') {
|
||||
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
const cur = await erp.list('Dispatch Job', { filters: [['name', '=', b.job]], fields: ['actual_start'], limit: 1 })
|
||||
const startTs = (cur && cur[0] && cur[0].actual_start) || null
|
||||
const now = nowFrappe()
|
||||
const patch = { actual_end: now, status: 'Completed' }; if (!startTs) patch.actual_start = now // borne si jamais démarré
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_minutes: minutesBetween(startTs || now, now) })
|
||||
}
|
||||
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
|
||||
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
|
||||
if (path === '/roster/backfill-start-times' && method === 'POST') {
|
||||
|
|
@ -1037,4 +1298,4 @@ async function handle (req, res, method, path, url) {
|
|||
return json(res, 404, { error: 'roster: route inconnue ' + path })
|
||||
}
|
||||
|
||||
module.exports = { handle, handlePublicBooking, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }
|
||||
module.exports = { handle, handlePublicBooking, handleFieldTech, fieldLink, techFieldLink, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }
|
||||
|
|
|
|||
|
|
@ -79,21 +79,21 @@ const EQUIP_SCHEMA = {
|
|||
required: ['serial_number'],
|
||||
}
|
||||
|
||||
// Extraction équipement réutilisable (étiquette modem/ONU → marque/modèle/série/MAC). base64 SANS préfixe data:.
|
||||
async function extractEquipment (base64) {
|
||||
const parsed = await geminiVision(base64, EQUIP_PROMPT, EQUIP_SCHEMA)
|
||||
if (!parsed) return { serial_number: null, barcodes: [] }
|
||||
if (parsed.mac_address) parsed.mac_address = parsed.mac_address.replace(/[:\-.\s]/g, '').toUpperCase()
|
||||
if (parsed.serial_number) parsed.serial_number = parsed.serial_number.replace(/\s+/g, '').trim()
|
||||
log(`Vision equipment: brand=${parsed.brand} model=${parsed.model} sn=${parsed.serial_number} mac=${parsed.mac_address}`)
|
||||
return parsed
|
||||
}
|
||||
async function handleEquipment (req, res) {
|
||||
const body = await parseBody(req)
|
||||
const check = extractBase64(req, body, 'equipment')
|
||||
if (check.error) return json(res, check.status, { error: check.error })
|
||||
try {
|
||||
const parsed = await geminiVision(check.base64, EQUIP_PROMPT, EQUIP_SCHEMA)
|
||||
if (!parsed) return json(res, 200, { serial_number: null, barcodes: [] })
|
||||
if (parsed.mac_address) parsed.mac_address = parsed.mac_address.replace(/[:\-.\s]/g, '').toUpperCase()
|
||||
if (parsed.serial_number) parsed.serial_number = parsed.serial_number.replace(/\s+/g, '').trim()
|
||||
log(`Vision equipment: brand=${parsed.brand} model=${parsed.model} sn=${parsed.serial_number} mac=${parsed.mac_address}`)
|
||||
return json(res, 200, parsed)
|
||||
} catch (e) {
|
||||
log('Vision equipment error:', e.message)
|
||||
return json(res, 500, { error: 'Vision extraction failed: ' + e.message })
|
||||
}
|
||||
try { return json(res, 200, await extractEquipment(check.base64)) }
|
||||
catch (e) { log('Vision equipment error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
|
||||
}
|
||||
|
||||
// ─── Invoice / bill OCR ────────────────────────────────────────────────
|
||||
|
|
@ -249,4 +249,4 @@ async function handleFieldScan (req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, handleInvoice, extractField, handleFieldScan }
|
||||
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, extractEquipment, handleInvoice, extractField, handleFieldScan }
|
||||
|
|
|
|||
148
services/targo-hub/public/field-app.html
Normal file
148
services/targo-hub/public/field-app.html
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<!doctype html><html lang=fr><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover,maximum-scale=1">
|
||||
<title>Targo Tech</title>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.css" rel=stylesheet>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.js"></script>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e2e8f0}
|
||||
.wrap{max-width:640px;margin:0 auto;padding:12px;padding-bottom:80px}
|
||||
.card{background:#1e293b;border-radius:14px;padding:14px;margin-bottom:10px}
|
||||
h1{font-size:17px;margin:0 0 2px}.sub{color:#94a3b8;font-size:13px}
|
||||
.badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px}
|
||||
.b-todo{background:#334155;color:#cbd5e1}.b-run{background:#064e3b;color:#6ee7b7}.b-done{background:#1e3a8a;color:#bfdbfe}
|
||||
.job{display:flex;gap:10px;align-items:center;cursor:pointer}
|
||||
.map{height:230px;border-radius:10px;overflow:hidden;margin:8px 0}
|
||||
.big{font-size:16px;font-weight:700;padding:15px;border-radius:12px;text-align:center;border:0;width:100%;color:#fff}
|
||||
.start{background:#16a34a}.stop{background:#ea580c}.doneb{background:#1e40af}
|
||||
.acts{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:10px}
|
||||
.acts a,.acts button{background:#334155;color:#e2e8f0;border:0;border-radius:10px;padding:12px 6px;font-size:13px;text-align:center;text-decoration:none;font-weight:600}
|
||||
.top{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
||||
.lnk{color:#60a5fa;text-decoration:none;font-size:13px}
|
||||
.geo{font-size:12px;color:#94a3b8;margin-top:6px}.warn{color:#fca5a5}
|
||||
.modal{position:fixed;inset:0;background:rgba(0,0,0,.85);display:none;flex-direction:column;padding:14px;z-index:50}
|
||||
.modal video{width:100%;border-radius:10px;background:#000}
|
||||
.modal input{width:100%;padding:12px;border-radius:8px;border:1px solid #475569;background:#0f172a;color:#fff;font-size:15px;margin-top:8px}
|
||||
.toast{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);background:#0b3d2e;color:#6ee7b7;padding:10px 16px;border-radius:10px;font-size:13px;display:none;z-index:60}
|
||||
.hbtn{background:#334155;border:0;color:#e2e8f0;border-radius:8px;padding:8px 10px;font-size:14px}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class=wrap>
|
||||
<!-- LISTE -->
|
||||
<div id=list>
|
||||
<h1>Mes interventions <span class=sub id=techname></span></h1>
|
||||
<div id=jobs class=sub>Chargement…</div>
|
||||
</div>
|
||||
<!-- DÉTAIL -->
|
||||
<div id=detail style=display:none>
|
||||
<div class=top><button class=hbtn onclick=showList()>‹ Retour</button><span class=badge id=dbadge></span></div>
|
||||
<div class=card>
|
||||
<h1 id=dsub></h1><div class=sub id=dcust></div>
|
||||
<div class=sub id=daddr style=margin-top:4px></div>
|
||||
<div style=margin-top:6px>
|
||||
<a class=lnk id=ditin target=_blank>🧭 Itinéraire</a> <a class=lnk id=dsv target=_blank>👁 Street View</a>
|
||||
</div>
|
||||
<div class=map id=map></div>
|
||||
<div class=geo id=dgeo></div>
|
||||
<button id=chrono class="big start" onclick=toggleChrono()>📍 Je suis arrivé</button>
|
||||
<div class=acts>
|
||||
<a id=areply class=lnk style="background:#334155;border-radius:10px;padding:12px 6px;text-align:center" target=_blank>💬 Répondre</a>
|
||||
<button onclick=document.getElementById('photo').click()>📷 Photo</button>
|
||||
<button onclick=openScan()>📦 Appareil</button>
|
||||
</div>
|
||||
<input type=file id=photo accept=image/* capture=environment style=display:none onchange=sendPhoto(this)>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SCAN -->
|
||||
<div class=modal id=scan>
|
||||
<div class=top><button class=hbtn onclick=closeScan()>✕ Fermer</button><span class=sub>Scanner code-barre / saisir</span></div>
|
||||
<video id=cam playsinline></video>
|
||||
<button class=big style="background:#7c3aed;margin-top:8px" onclick=visionScan()>📸 Lire l'étiquette (IA)</button>
|
||||
<div class=sub id=vismsg style=margin-top:4px></div>
|
||||
<input type=file id=scanphoto accept=image/* capture=environment style=display:none onchange=visionFile(this)>
|
||||
<input id=serial placeholder="N° de série">
|
||||
<input id=mac placeholder="MAC (optionnel)">
|
||||
<button class="big doneb" style=margin-top:10px onclick=sendDevice()>Enregistrer l'appareil</button>
|
||||
</div>
|
||||
<div class=toast id=toast></div>
|
||||
<script>
|
||||
const HUB='__HUB__', MAPBOX='__MAPBOX_TOKEN__', T=new URLSearchParams(location.search).get('t')||'';
|
||||
const CAP=window.Capacitor, NATIVE=!!(CAP&&CAP.isNativePlatform&&CAP.isNativePlatform()), MLKIT=CAP&&CAP.Plugins&&CAP.Plugins.BarcodeScanning;
|
||||
const $=id=>document.getElementById(id); let JOBS=[],CUR=null,map=null,jobMk=null,meMk=null,watch=null,enteredZone=false;
|
||||
function toast(m){const t=$('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2600)}
|
||||
function badge(j){return j.ended?'<span class="badge b-done">terminé'+(j.minutes!=null?' · '+j.minutes+'m':'')+'</span>':j.started?'<span class="badge b-run">en cours</span>':'<span class="badge b-todo">à faire</span>'}
|
||||
async function api(p,o){const r=await fetch(HUB+p,o);return r.json()}
|
||||
function dist(a1,o1,a2,o2){const R=6371000,t=Math.PI/180,dA=(a2-a1)*t,dO=(o2-o1)*t;const x=Math.sin(dA/2)**2+Math.cos(a1*t)*Math.cos(a2*t)*Math.sin(dO/2)**2;return Math.round(R*2*Math.atan2(Math.sqrt(x),Math.sqrt(1-x)))}
|
||||
async function load(){
|
||||
const r=await api('/field/tech?t='+encodeURIComponent(T)).catch(()=>({}));
|
||||
if(r&&r.ok){JOBS=r.jobs||[];$('techname').textContent='· '+(r.tech||'');renderList();return}
|
||||
// sinon : token de job unique
|
||||
const j=await api('/field/job?t='+encodeURIComponent(T)).catch(()=>({}));
|
||||
if(j&&j.ok){JOBS=[{...j.job,token:T,start:'',started:!!j.job.actual_start,ended:!!j.job.actual_end,minutes:j.job.actual_minutes,lat:j.job.latitude,lon:j.job.longitude}];openDetail(0);return}
|
||||
$('jobs').innerHTML='<span class=warn>Lien invalide ou expiré.</span>';
|
||||
}
|
||||
function renderList(){
|
||||
if(!JOBS.length){$('jobs').innerHTML='Aucune intervention.';return}
|
||||
$('jobs').innerHTML='';
|
||||
JOBS.forEach((j,i)=>{const d=document.createElement('div');d.className='card job';d.onclick=()=>openDetail(i);
|
||||
d.innerHTML='<div style=flex:1><div style=font-weight:600>'+esc(j.subject||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(j.address||'')+'</div></div>'+badge(j);
|
||||
$('jobs').appendChild(d)});
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]))}
|
||||
function showList(){if(watch){navigator.geolocation.clearWatch(watch);watch=null}if(map){map.remove();map=null}$('detail').style.display='none';$('list').style.display='block';load()}
|
||||
function openDetail(i){
|
||||
CUR=JOBS[i];enteredZone=false;$('list').style.display='none';$('detail').style.display='block';
|
||||
$('dsub').textContent=CUR.subject||'Job';$('dcust').textContent=CUR.customer||'';$('daddr').textContent='📍 '+(CUR.address||'(adresse manquante)');
|
||||
$('dbadge').innerHTML=badge(CUR);
|
||||
const hasLL=CUR.lat&&Math.abs(+CUR.lat)>0.01;
|
||||
$('ditin').href=hasLL?('https://www.google.com/maps/dir/?api=1&destination='+CUR.lat+','+CUR.lon):'#';
|
||||
$('dsv').href=hasLL?('https://www.google.com/maps/@?api=1&map_action=pano&viewpoint='+CUR.lat+','+CUR.lon):'#';
|
||||
$('areply').href=CUR.reply||'#';$('areply').style.opacity=CUR.reply?1:.4;
|
||||
setChrono();initMap(hasLL);startWatch(hasLL);
|
||||
}
|
||||
function setChrono(){const b=$('chrono');if(CUR.ended){b.className='big doneb';b.textContent='✅ Terminé'+(CUR.minutes!=null?' · '+CUR.minutes+' min':'');b.disabled=true}
|
||||
else if(CUR.started){b.className='big stop';b.textContent='⏹ J\'ai terminé';b.disabled=false}
|
||||
else{b.className='big start';b.textContent='📍 Je suis arrivé';b.disabled=false}}
|
||||
async function toggleChrono(){
|
||||
let pos=null;try{pos=await getPos()}catch(e){}
|
||||
const type=CUR.started?'geo_exit':'geo_enter';
|
||||
const r=await checkpoint(type,pos);
|
||||
if(r&&r.ok){if(type==='geo_enter'){CUR.started=true}else{CUR.ended=true;CUR.minutes=r.minutes}setChrono();$('dbadge').innerHTML=badge(CUR);toast(type==='geo_enter'?'Arrivée enregistrée':'Terminé · '+(r.minutes||0)+' min')}
|
||||
}
|
||||
async function checkpoint(type,pos){const body={token:CUR.token,type};if(pos){body.lat=pos.coords.latitude;body.lon=pos.coords.longitude;body.acc=pos.coords.accuracy}
|
||||
return api('/field/checkpoint',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).catch(()=>({ok:false}))}
|
||||
function getPos(){return new Promise((res,rej)=>navigator.geolocation.getCurrentPosition(res,rej,{enableHighAccuracy:true,timeout:10000}))}
|
||||
function initMap(hasLL){mapboxgl.accessToken=MAPBOX;map=new mapboxgl.Map({container:'map',style:'mapbox://styles/mapbox/streets-v12',center:hasLL?[+CUR.lon,+CUR.lat]:[-73.9,45.18],zoom:hasLL?15:9});
|
||||
map.on('load',()=>{map.resize();if(hasLL){jobMk=new mapboxgl.Marker({color:'#ea580c'}).setLngLat([+CUR.lon,+CUR.lat]).addTo(map)}})}
|
||||
function startWatch(hasLL){if(!navigator.geolocation)return;
|
||||
watch=navigator.geolocation.watchPosition(p=>{const ll=[p.coords.longitude,p.coords.latitude];
|
||||
if(map){if(!meMk){meMk=new mapboxgl.Marker({color:'#38bdf8'}).setLngLat(ll).addTo(map)}else meMk.setLngLat(ll)}
|
||||
if(hasLL){const d=dist(+CUR.lat,+CUR.lon,p.coords.latitude,p.coords.longitude);$('dgeo').textContent='à '+d+' m de l\'adresse (±'+Math.round(p.coords.accuracy)+' m)';
|
||||
if(d<=250&&!enteredZone&&!CUR.started){enteredZone=true;CUR.started=true;setChrono();$('dbadge').innerHTML=badge(CUR);checkpoint('geo_enter',p);toast('Arrivée détectée (auto)')}}
|
||||
},e=>{$('dgeo').innerHTML='<span class=warn>Active la localisation pour le suivi auto</span>'},{enableHighAccuracy:true,maximumAge:15000,timeout:20000})}
|
||||
// PHOTO : redimensionne puis envoie en base64
|
||||
function sendPhoto(inp){const f=inp.files[0];if(!f)return;const img=new Image();const rd=new FileReader();
|
||||
rd.onload=()=>{img.onload=()=>{const s=Math.min(1,1280/Math.max(img.width,img.height));const c=document.createElement('canvas');c.width=img.width*s;c.height=img.height*s;c.getContext('2d').drawImage(img,0,0,c.width,c.height);
|
||||
const data=c.toDataURL('image/jpeg',0.7);api('/field/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,image:data})}).then(r=>toast(r&&r.ok?'📷 Photo envoyée':'Échec photo'))};img.src=rd.result};rd.readAsDataURL(f);inp.value=''}
|
||||
// SCAN : BarcodeDetector (Android) sinon saisie manuelle ; en build natif, plugin MLKit prend le relais
|
||||
let scanStream=null,scanTimer=null;
|
||||
async function openScan(){$('scan').style.display='flex';$('serial').value='';$('mac').value='';$('vismsg').textContent='';
|
||||
if(MLKIT){$('cam').style.display='none';mlkitScan();return} // NATIF : MLKit on-device (instantané, hors-ligne) — pas de latence Gemini
|
||||
try{scanStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'environment'}});$('cam').srcObject=scanStream;$('cam').play();$('cam').style.display='block';
|
||||
if('BarcodeDetector'in window){const det=new BarcodeDetector();scanTimer=setInterval(async()=>{try{const codes=await det.detect($('cam'));if(codes[0]){const v=codes[0].rawValue;if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(v))$('mac').value=v;else $('serial').value=v;navigator.vibrate&&navigator.vibrate(80)}}catch(e){}},700)}
|
||||
}catch(e){$('cam').style.display='none'}}
|
||||
function closeScan(){if(scanTimer)clearInterval(scanTimer);if(scanStream){scanStream.getTracks().forEach(t=>t.stop());scanStream=null}$('scan').style.display='none'}
|
||||
// MLKit natif (on-device, hors-ligne, instantané) — via le plugin Capacitor @capacitor-mlkit/barcode-scanning
|
||||
async function mlkitScan(){try{$('vismsg').textContent='⚡ Scanner…';const r=await MLKIT.scan();const codes=(r&&r.barcodes)||[];
|
||||
if(codes.length){const v=codes[0].rawValue||codes[0].displayValue||'';if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(v))$('mac').value=v.replace(/[:-]/g,'').toUpperCase();else $('serial').value=v;$('vismsg').textContent='✓ '+v;navigator.vibrate&&navigator.vibrate(80)}
|
||||
else $('vismsg').textContent='Aucun code-barre — « Lire l\'étiquette (IA) » pour du texte'}catch(e){$('vismsg').textContent='Scan annulé'}}
|
||||
// Photo de l'étiquette → proxy IA Gemini (cross-plateforme, lit le texte imprimé, pas que les code-barres)
|
||||
function visionScan(){if(scanStream&&$('cam').videoWidth){const v=$('cam');const c=document.createElement('canvas');c.width=v.videoWidth;c.height=v.videoHeight;c.getContext('2d').drawImage(v,0,0);visionUpload(c.toDataURL('image/jpeg',0.7))}else $('scanphoto').click()}
|
||||
function visionFile(inp){const f=inp.files[0];if(!f)return;const img=new Image(),rd=new FileReader();rd.onload=()=>{img.onload=()=>{const s=Math.min(1,1280/Math.max(img.width,img.height));const c=document.createElement('canvas');c.width=img.width*s;c.height=img.height*s;c.getContext('2d').drawImage(img,0,0,c.width,c.height);visionUpload(c.toDataURL('image/jpeg',0.7))};img.src=rd.result};rd.readAsDataURL(f);inp.value=''}
|
||||
async function visionUpload(data){$('vismsg').textContent='🔎 Lecture IA…';const r=await api('/field/vision',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,image:data})}).catch(()=>({ok:false}));
|
||||
if(r&&r.ok){if(r.serial_number)$('serial').value=r.serial_number;if(r.mac_address)$('mac').value=r.mac_address;$('vismsg').textContent='✓ '+([r.brand,r.model].filter(Boolean).join(' ')+(r.serial_number?' · SN '+r.serial_number:'')+(r.mac_address?' · MAC '+r.mac_address:'')||'lu, vérifie les champs');navigator.vibrate&&navigator.vibrate(80)}else $('vismsg').textContent='Échec lecture IA — saisis à la main'}
|
||||
async function sendDevice(){const serial=$('serial').value.trim(),mac=$('mac').value.trim();if(!serial&&!mac){toast('Série ou MAC requis');return}
|
||||
const r=await api('/field/device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,serial,mac})}).catch(()=>({ok:false}));
|
||||
if(r&&r.ok){CUR.started=true;setChrono();$('dbadge').innerHTML=badge(CUR);toast('📦 Appareil enregistré');closeScan()}else toast('Échec')}
|
||||
load();
|
||||
</script></body></html>
|
||||
|
|
@ -134,6 +134,8 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path.startsWith('/roster')) return require('./lib/roster').handle(req, res, method, path, url)
|
||||
// Portail public de prise de RDV (staging) — page + API client, PUBLIC (pas de SSO).
|
||||
if (path === '/book' || path.startsWith('/book/')) return require('./lib/roster').handlePublicBooking(req, res, method, path, url)
|
||||
// App technicien — capture passive du temps (checkpoints GPS/scan), token signé par job, PUBLIC (pas de SSO).
|
||||
if (path === '/field' || path.startsWith('/field/')) return require('./lib/roster').handleFieldTech(req, res, method, path, url)
|
||||
// Portail self-service d'abonnement (staging) — page + submit, PUBLIC.
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return require('./lib/signup').handle(req, res, method, path)
|
||||
// Boutique matériel (page modèle, staging) — page + (à venir) catalogue ERPNext, PUBLIC.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user