feat(dispatch): pont d'écriture legacy + adresses/coords/carte/UX Planification

Pont d'écriture Ops → legacy osTicket via endpoint PHP token-gated (hérite des
droits write de l'app facturation.targo.ca → AUCUN grant DB). Fidèle à ticket_view.php :
réassignation (assign_to + participant[assistants] + followed_by + ticket_msg + lock
respecté) + fermeture (status=closed + date_closed + closed_by + réouverture des enfants).
Auteur du log = utilisateur Authentik réel (email → staff legacy par email/nom).

Hub (legacy-dispatch-sync.js):
- adresse de SERVICE importée (champ address) + reimportAddresses + fillMissingCoords
- garde TERRITOIRE (rejette les homonymes hors-zone Gaspésie/Outaouais/Estrie) + centroïde CP/ville
- purgeStaleOrphans (anciens imports TT- périmés), # ticket legacy dans titre + description
- legacyWrite (POST form + X-Ops-Token lu d'un fichier), pushAssignments (reassign), closeTicketLegacy
roster.js: occupancy + unassigned-jobs exposent address/latitude/longitude.

Ops (PlanificationPage.vue):
- carte des jobs à assigner (pins couleur=compétence + lettre repère liste↔carte), chips
  filtre par type, panneau ouvert par défaut
- clics cellule: bande quart/garde + blocs jobs → tournée ; fond → menu horaire (fini le clic droit)
- éditeur de tournée: itinéraire routier réel + adresse + fil ticket (expand), refresh occupation à l'assignation
- bouton « Publier au legacy » (aperçu + ✕ désassigner) + « Fermer le ticket »
- fix dates (lundi calculé en local — plus de décalage UTC) + nav ±1 jour

services/legacy-bridge/ops_reassign.php: endpoint (placeholders; secrets injectés au déploiement, hors repo).
scripts/migration: backfill_dispatch_address.sql + diagnostics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-07 22:32:00 -04:00
parent 4a3dc01938
commit 4abce6fd66
8 changed files with 765 additions and 48 deletions

View File

@ -91,6 +91,13 @@ export const jobCandidates = (job, exclude) => jget('/roster/job-candidates?job=
export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan }) export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan })
// Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer // Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer
export const unassignedJobs = () => jget('/roster/unassigned-jobs') 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', {})
// 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)
export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close-ticket?ticket=' + encodeURIComponent(ticket), {})
// Assigner un job à un tech (date = case déposée) // Assigner un job à un tech (date = case déposée)
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date }) export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only // Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only

View File

@ -7,9 +7,11 @@
<q-chip v-if="offShiftWeekCount" dense size="sm" color="orange-8" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour la ressource n'a AUCUN quart publié. Repère le dans la grille publier un quart ou réassigner.</q-tooltip></q-chip> <q-chip v-if="offShiftWeekCount" dense size="sm" color="orange-8" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour la ressource n'a AUCUN quart publié. Repère le dans la grille publier un quart ou réassigner.</q-tooltip></q-chip>
<q-space /> <q-space />
<q-btn-group flat> <q-btn-group flat>
<q-btn dense flat icon="chevron_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn> <q-btn dense flat icon="keyboard_double_arrow_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
<q-btn dense flat icon="chevron_left" @click="navDay(-1)"><q-tooltip>Jour précédent</q-tooltip></q-btn>
<q-btn dense flat label="Auj." @click="navToday" /> <q-btn dense flat label="Auj." @click="navToday" />
<q-btn dense flat icon="chevron_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn> <q-btn dense flat icon="chevron_right" @click="navDay(1)"><q-tooltip>Jour suivant</q-tooltip></q-btn>
<q-btn dense flat icon="keyboard_double_arrow_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
</q-btn-group> </q-btn-group>
<q-input dense outlined type="date" v-model="start" style="width:150px" @update:model-value="onWeekChange" /> <q-input dense outlined type="date" v-model="start" style="width:150px" @update:model-value="onWeekChange" />
<q-select dense outlined v-model="days" :options="[7, 14]" style="width:76px" emit-value map-options @update:model-value="onDaysChange" /> <q-select dense outlined v-model="days" :options="[7, 14]" style="width:76px" emit-value map-options @update:model-value="onDaysChange" />
@ -55,6 +57,7 @@
<q-btn unelevated color="primary" icon="auto_awesome" label="Générer" :loading="generating" @click="doGenerate" /> <q-btn unelevated color="primary" icon="auto_awesome" label="Générer" :loading="generating" @click="doGenerate" />
<q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox> <q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox>
<q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" /> <q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
<q-btn outline color="deep-orange" icon="sync_alt" label="Publier au legacy" @click="openLegacyPush"><q-tooltip class="bg-grey-9">Réassigne les tickets aux techniciens DANS le système legacy (osTicket) selon l'assignation Ops — aperçu avant d'écrire.</q-tooltip></q-btn>
<q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" /> <q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" />
</div> </div>
@ -152,7 +155,6 @@
<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 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)"> <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 class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
<q-badge v-if="gapByDay[d.iso]" color="red" floating style="top:2px;right:2px">{{ gapByDay[d.iso] }}</q-badge>
<div class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</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> </th>
</tr> </tr>
@ -177,18 +179,19 @@
<q-btn flat dense round size="9px" class="hide-eye" :icon="isHidden(t.id) ? 'visibility_off' : 'visibility'" :color="isHidden(t.id) ? 'grey-5' : 'grey-6'" @click.stop="toggleHidden(t.id)"><q-tooltip>{{ isHidden(t.id) ? 'Réafficher / considérer cette ressource' : 'Masquer & ignorer (hors front-line)' }}</q-tooltip></q-btn> <q-btn flat dense round size="9px" class="hide-eye" :icon="isHidden(t.id) ? 'visibility_off' : 'visibility'" :color="isHidden(t.id) ? 'grey-5' : 'grey-6'" @click.stop="toggleHidden(t.id)"><q-tooltip>{{ isHidden(t.id) ? 'Réafficher / considérer cette ressource' : 'Masquer & ignorer (hors front-line)' }}</q-tooltip></q-btn>
</div> </div>
</td> </td>
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)"> <td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @contextmenu.prevent="onCellContext(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)">
<template v-if="isAbsent(t.id, d.iso) || isPaused(t)"> <template v-if="isAbsent(t.id, d.iso) || isPaused(t)">
<div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div> <div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div>
</template> </template>
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso)"> <template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso)">
<div class="tl"> <div class="tl">
<div v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div> <div v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift tl-shift-click" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }" @click.stop="openDayFromCell(t, d)"><q-tooltip class="bg-grey-9" :delay="500" style="font-size:11px">{{ b.oncall ? 'Garde voir le jour' : 'Quart publié voir les jobs / la tournée' }}</q-tooltip></div>
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayEditor(t, d)" @mousedown.stop><q-tooltip class="bg-grey-9" :delay="400" style="font-size:11px">Éditer la tournée du jour</q-tooltip></div> <div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayFromCell(t, d)" @mousedown.stop><q-tooltip class="bg-grey-9" :delay="400" style="font-size:11px">Clic = voir la tournée / les jobs</q-tooltip></div>
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta --> <!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
<div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div> <div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div>
<q-tooltip class="bg-grey-9" :offset="[0, 6]" max-width="320px"> <q-tooltip class="bg-grey-9" :offset="[0, 6]" max-width="320px">
<div class="text-weight-medium">{{ cellTip(t.id, d.iso) }}</div> <div class="text-weight-medium">{{ cellTip(t.id, d.iso) }}</div>
<div class="text-blue-3" style="font-size:10px">Clic sur le quart / les jobs = tournée · clic sur le fond = horaire · glisser = sélection</div>
<template v-if="cellJobs(t.id, d.iso).length"> <template v-if="cellJobs(t.id, d.iso).length">
<div class="text-amber-4 q-mt-xs" style="font-size:11px">{{ cellJobs(t.id, d.iso).length }} job(s) · par priorité</div> <div class="text-amber-4 q-mt-xs" style="font-size:11px">{{ cellJobs(t.id, d.iso).length }} job(s) · par priorité</div>
<div v-for="j in cellJobs(t.id, d.iso)" :key="j.name" class="row items-center no-wrap" style="gap:5px;font-size:11px;line-height:1.5"> <div v-for="j in cellJobs(t.id, d.iso)" :key="j.name" class="row items-center no-wrap" style="gap:5px;font-size:11px;line-height:1.5">
@ -218,14 +221,7 @@
</table> </table>
</div> </div>
<div class="text-subtitle2 text-weight-bold q-mt-lg q-mb-sm">Couverture dispo vs requis</div> <!-- Matrice « Couverture dispo vs requis » retirée : le besoin = toutes les spécialités chaque jour (règle simple, à automatiser via AI plus tard). -->
<div v-if="!covRows.length" class="text-grey-6 q-mb-md">Aucun besoin défini. Utilise « Demande » « Appliquer à la semaine ».</div>
<div v-else class="grid-wrap">
<table class="roster-grid">
<thead><tr><th class="tech-col">Créneau</th><th v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }"><div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div></th></tr></thead>
<tbody><tr v-for="row in covRows" :key="row.key"><td class="tech-col">{{ row.label }}</td><td v-for="d in dayList" :key="d.iso" class="cell cov" :style="covStyle(row.key, d.iso)">{{ covText(row.key, d.iso) }}</td></tr></tbody>
</table>
</div>
<q-dialog v-model="showShiftEditor"> <q-dialog v-model="showShiftEditor">
<q-card style="min-width:580px"> <q-card style="min-width:580px">
@ -532,9 +528,10 @@
</q-dialog> </q-dialog>
<!-- Panneau FLOTTANT déplaçable : jobs à assigner (groupes parent-enfant) glisser sur une case (tech × jour) --> <!-- Panneau FLOTTANT déplaçable : jobs à assigner (groupes parent-enfant) glisser sur une case (tech × jour) -->
<div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px' }"> <div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px', width: assignPanel.w + 'px', height: assignPanel.h + 'px', maxHeight: '92vh' }">
<div class="assign-hdr" @mousedown="panelHeaderDown"> <div class="assign-hdr" @mousedown="panelHeaderDown">
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignPanel.jobs.length }})</span><q-space /> <q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
<q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" /> <q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" /> <q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
</div> </div>
@ -548,6 +545,15 @@
<option value="priority">Priorité</option> <option value="priority">Priorité</option>
</select> </select>
</div> </div>
<div v-if="assignTypes.length > 1" class="assign-chips" @mousedown.stop>
<span v-for="t in assignTypes" :key="t.k" class="assign-chip-f" :style="assignTypeFilter.includes(t.k) ? { background: getTagColor(t.k), color: '#fff', borderColor: getTagColor(t.k) } : { borderColor: getTagColor(t.k) }" @click="toggleAssignType(t.k)">{{ t.k }} <b>{{ t.n }}</b></span>
<span v-if="assignTypeFilter.length" class="assign-chip-f clear" @click="assignTypeFilter = []"><q-tooltip>Tout afficher</q-tooltip></span>
</div>
<!-- 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>
<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"> <div class="assign-body">
<div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement</div> <div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement</div>
<div v-else-if="!assignPanel.jobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div> <div v-else-if="!assignPanel.jobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div>
@ -558,15 +564,25 @@
<div class="row items-center no-wrap"> <div class="row items-center no-wrap">
<q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" /> <q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon> <q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
<span v-if="groupLabel(j)" class="assign-grp-badge q-mr-xs"><q-tooltip class="bg-grey-9">Même adresse de service (groupe {{ groupLabel(j)[0] }}) simple repère de proximité</q-tooltip>{{ groupLabel(j) }}</span>
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge> <q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
<span class="ellipsis text-weight-medium">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span> <span class="ellipsis text-weight-medium" :style="assignPanel.showMap ? 'cursor:pointer' : ''" @click="assignPanel.showMap && focusAssignJob(j)">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
<q-space /> <q-space />
<q-icon v-if="j.legacy_ticket_id" name="forum" size="14px" :color="j._showThread ? 'indigo' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop="toggleAssignThread(j)" @mousedown.stop><q-tooltip>Voir le fil du ticket #{{ j.legacy_ticket_id }}</q-tooltip></q-icon>
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon> <q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
</div> </div>
<div class="assign-sub"> <div class="assign-sub">
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span> <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 {{ 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
</div> </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>
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="assign-msg"><b>{{ m.author }}</b><span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><div class="assign-msg-txt">{{ m.text }}</div></div>
</template>
<div v-else class="text-grey-6" style="font-size:11px;white-space:pre-wrap">{{ j.legacy_detail || 'Aucun fil pour ce ticket.' }}</div>
<div class="text-right q-mt-xs"><q-btn dense flat size="sm" color="negative" icon="block" label="Fermer ce ticket (legacy)" @click="closeLegacyTicket(j)"><q-tooltip>Ticket inutile status closed dans osTicket + sort du dispatch</q-tooltip></q-btn></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -574,8 +590,45 @@
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b> glisse la sélection sur un tech</template> <template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b> glisse la sélection sur un tech</template>
<template v-else>Coche des jobs (groupe lié surligné), puis glisse la sélection sur une case <b>tech × jour</b>. <q-icon name="home_repair_service" size="11px" color="teal" />=terrain · <q-icon name="cloud" size="11px" color="grey-5" />=à distance · 🔒=bloqué.</template> <template v-else>Coche des jobs (groupe lié surligné), puis glisse la sélection sur une case <b>tech × jour</b>. <q-icon name="home_repair_service" size="11px" color="teal" />=terrain · <q-icon name="cloud" size="11px" color="grey-5" />=à distance · 🔒=bloqué.</template>
</div> </div>
<div class="assign-resize" @mousedown.stop="panelResizeDown"><q-tooltip>Redimensionner</q-tooltip></div>
</div> </div>
<!-- Write-back legacy : aperçu des réassignations osTicket confirmer avant d'écrire dans le legacy -->
<q-dialog v-model="legacyPush.open">
<q-card style="min-width:540px;max-width:660px">
<q-card-section class="row items-center q-pb-sm">
<q-icon name="sync_alt" color="deep-orange" size="22px" class="q-mr-sm" />
<div>
<div class="text-subtitle1 text-weight-bold">Publier les assignations au legacy</div>
<div class="text-caption text-grey-7">Réassigne <code>ticket.assign_to</code> au technicien dans osTicket (selon l'assignation Ops).</div>
</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-none">
<div v-if="legacyPush.loading" class="text-grey-6 q-pa-md text-center"><q-spinner size="20px" class="q-mr-sm" />Analyse des assignations</div>
<template v-else-if="legacyPush.preview">
<q-banner v-if="!legacyPush.preview.pushed" dense rounded class="bg-blue-1 text-blue-9 q-mb-sm"><template #avatar><q-icon name="info" /></template>Rien à pousser. Assigne d'abord des jobs à des techs (glisser-déposer), puis reviens ici.</q-banner>
<div v-else class="text-body2 q-mb-sm"><b class="text-deep-orange-8">{{ legacyPush.preview.pushed }}</b> ticket(s) seront réassignés dans le legacy<span v-if="legacyPush.preview.skipped" class="text-grey-6"> · {{ legacyPush.preview.skipped }} déjà à jour/fermé(s)</span><span v-if="legacyPush.preview.mismatch" class="text-deep-orange-7"> · {{ legacyPush.preview.mismatch }} ignoré(s) (nom )</span>.</div>
<q-list dense bordered class="rounded-borders" style="max-height:300px;overflow:auto">
<q-item v-for="(s, i) in (legacyPush.preview.samples || [])" :key="i" class="q-py-xs">
<q-item-section avatar style="min-width:28px"><q-icon :name="s.action ? 'warning' : 'arrow_forward'" :color="s.action ? 'deep-orange' : 'green'" size="16px" /></q-item-section>
<q-item-section>
<q-item-label v-if="s.action" style="font-size:12px" class="text-deep-orange-8">Ticket #{{ s.ticket }} Ops « {{ s.ops_nom }} » legacy « {{ s.legacy_staff }} » <b>ignoré</b></q-item-label>
<q-item-label v-else style="font-size:12px">Ticket #{{ s.ticket }} <b>{{ s.vers_staff }}</b><span class="text-grey-6"> · {{ s.sujet }}</span></q-item-label>
</q-item-section>
<q-item-section side v-if="s.job"><q-btn flat dense round size="sm" icon="close" color="negative" :disable="legacyPush.applying" @click="removeFromPush(s)"><q-tooltip>Désassigner ce job (retour au pool) ne sera pas poussé</q-tooltip></q-btn></q-item-section>
</q-item>
</q-list>
<div class="text-caption text-grey-6 q-mt-xs">Sécurité : seuls les tickets <b>ouverts</b> sont touchés, le nom du staff legacy doit concorder, et c'est idempotent.</div>
</template>
</q-card-section>
<q-card-actions align="right">
<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>
<!-- Timeline contextuelle d'une ressource : dispatch des jobs de la semaine visible (heures + priorités) --> <!-- Timeline contextuelle d'une ressource : dispatch des jobs de la semaine visible (heures + priorités) -->
<q-dialog v-model="timelineDlg.open"> <q-dialog v-model="timelineDlg.open">
<q-card style="min-width:560px;max-width:780px"> <q-card style="min-width:560px;max-width:780px">
@ -701,16 +754,27 @@
<q-icon name="drag_indicator" size="16px" class="text-grey-5" style="cursor:grab" /> <q-icon name="drag_indicator" size="16px" class="text-grey-5" style="cursor:grab" />
<span class="de-ord">{{ i + 1 }}</span> <span class="de-ord">{{ i + 1 }}</span>
<span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span> <span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span>
<div class="col" style="min-width:0;cursor:pointer" @click="j.showDetail = !j.showDetail; focusDayJob(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="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-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 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> </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> <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 --> <!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) gain de place ; priorité gérée au Dispatch -->
<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="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> <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> </div>
<div v-if="j.showDetail" class="de-detail">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}</div> <div v-if="j.showDetail" class="de-detail">
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="13px" class="q-mr-xs" />Chargement du fil</div>
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ j._thread.messages.length }} message(s){{ j._thread.status ? ' · ' + j._thread.status : '' }}</div>
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="de-msg">
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
<div class="de-msg-txt">{{ m.text }}</div>
</div>
</template>
<div v-else style="white-space:pre-wrap;font-size:11px">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}<span v-if="j._thread && j._thread.error" class="text-deep-orange-7"> · fil indisponible</span></div>
</div>
</template> </template>
</q-card-section> </q-card-section>
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none"> <q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
@ -797,8 +861,10 @@ const newTplRange = computed({ get: () => ({ min: hToNum(newTpl.start) || 8, max
const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1' const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'
function upcomingMonday () { const d = new Date(); d.setDate(d.getDate() + ((1 - d.getDay() + 7) % 7)); return d.toISOString().slice(0, 10) } // Date LOCALE du jour (PAS toISOString, qui bascule au lendemain le soir en UTC cause des « jours décalés »).
function thisMonday () { const d = new Date(); const diff = (d.getDay() === 0 ? -6 : 1) - d.getDay(); d.setDate(d.getDate() + diff); return d.toISOString().slice(0, 10) } function todayISO () { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') }
function upcomingMonday () { const t = todayISO(); return addDaysISO(t, (1 - dowOf(t) + 7) % 7) } // prochain lundi (aujourd'hui si lundi), calcul UTC-cohérent
function thisMonday () { return mondayISO(todayISO()) } // lundi de la semaine COURANTE (pour « Auj. »)
function addDaysISO (iso, n) { const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + n); return dt.toISOString().slice(0, 10) } function addDaysISO (iso, n) { const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + n); return dt.toISOString().slice(0, 10) }
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam'] const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
const dayList = computed(() => { const dayList = computed(() => {
@ -938,12 +1004,28 @@ async function doRedistribute (mode) { // « Tout à recontacter » (bascule sim
} catch (e) { err(e) } finally { redistributing.value = false } } catch (e) { err(e) } finally { redistributing.value = false }
} }
// Panneau FLOTTANT « jobs à assigner » (multi-sélection + glisser-déposer + aperçu d'occupation) // Panneau FLOTTANT « jobs à assigner » (multi-sélection + glisser-déposer + aperçu d'occupation)
const assignPanel = reactive({ open: false, x: 40, y: 130, jobs: [], loading: false }) const assignPanel = reactive({ open: false, x: 40, y: 130, w: 340, h: 560, jobs: [], loading: false, showMap: false })
const draggingJobName = ref(null); const dropCell = ref(null); const dragHours = ref(0) const draggingJobName = ref(null); const dropCell = ref(null); const dragHours = ref(0)
const selectedJobs = reactive({}) // jobName true const selectedJobs = reactive({}) // jobName true
const dropPreview = reactive({ key: null, addH: 0 }) const dropPreview = reactive({ key: null, addH: 0 })
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
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 } } 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 })
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 } }
// 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 }
legacyPush.applying = true
try {
await roster.unassignJobRoster(s.job)
await reloadOccupancy()
legacyPush.preview = await roster.pushLegacyPreview()
$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('group') // group (parent-enfant) | skill | date | city | priority
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
function jobCity (j) { function jobCity (j) {
@ -953,8 +1035,13 @@ function jobCity (j) {
const subj = String(j.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim() // sujets legacy « Ville | Nom » const subj = String(j.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim() // sujets legacy « Ville | Nom »
return parts[0] || 'Sans ville' return parts[0] || 'Sans ville'
} }
// Chips-filtres par compétence/type (installation, réparation, tv) dans le panneau d'assignation.
const assignTypeFilter = ref([]) // types sélectionnés (vide = tous)
const assignTypes = computed(() => { const m = {}; for (const j of assignPanel.jobs) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 } return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n) })
const assignJobsFiltered = computed(() => { const f = assignTypeFilter.value; if (!f.length) return assignPanel.jobs; const set = new Set(f); return assignPanel.jobs.filter(j => set.has(j.required_skill || 'autre')) })
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
const assignGroups = computed(() => { const assignGroups = computed(() => {
const jobs = assignPanel.jobs const jobs = assignJobsFiltered.value
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation), ordonné par step_order if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation), ordonné par step_order
const g = {}; for (const j of jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) } const g = {}; for (const j of jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) })) return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
@ -1007,13 +1094,85 @@ async function onCellDrop (ev, t, d) {
let ok = 0 let ok = 0
for (const jn of assignable) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg) for (const jn of assignable) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
assignPanel.jobs = assignPanel.jobs.filter(j => !assignable.includes(j.name)) // les bloqués restent dans le panneau assignPanel.jobs = assignPanel.jobs.filter(j => !assignable.includes(j.name)) // les bloqués restent dans le panneau
$q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await loadWeek() $q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await reloadOccupancy() // refresh rapide du timeline (blocs) sans loadWeek complet
} }
let _panelDrag = null // déplacement du panneau via son en-tête let _panelDrag = null // déplacement du panneau via son en-tête
function panelHeaderDown (ev) { _panelDrag = { dx: ev.clientX - assignPanel.x, dy: ev.clientY - assignPanel.y }; document.addEventListener('mousemove', panelMove); document.addEventListener('mouseup', panelUp) } function panelHeaderDown (ev) { _panelDrag = { dx: ev.clientX - assignPanel.x, dy: ev.clientY - assignPanel.y }; document.addEventListener('mousemove', panelMove); document.addEventListener('mouseup', panelUp) }
function panelMove (ev) { if (!_panelDrag) return; assignPanel.x = Math.max(0, ev.clientX - _panelDrag.dx); assignPanel.y = Math.max(0, ev.clientY - _panelDrag.dy) } function panelMove (ev) { if (!_panelDrag) return; assignPanel.x = Math.max(0, ev.clientX - _panelDrag.dx); assignPanel.y = Math.max(0, ev.clientY - _panelDrag.dy) }
function panelUp () { _panelDrag = null; document.removeEventListener('mousemove', panelMove); document.removeEventListener('mouseup', panelUp) } function panelUp () { _panelDrag = null; document.removeEventListener('mousemove', panelMove); document.removeEventListener('mouseup', panelUp) }
// Panneau d'assignation : redimensionnement (poignée coin)
let _panelResize = null
function panelResizeDown (ev) { _panelResize = { x: ev.clientX, y: ev.clientY, w: assignPanel.w, h: assignPanel.h }; document.addEventListener('mousemove', panelResizeMove); document.addEventListener('mouseup', panelResizeUp); ev.preventDefault() }
function panelResizeMove (ev) { if (!_panelResize) return; assignPanel.w = Math.max(300, Math.round(_panelResize.w + (ev.clientX - _panelResize.x))); assignPanel.h = Math.max(320, Math.round(_panelResize.h + (ev.clientY - _panelResize.y))); if (_assignMap) _assignMap.resize() }
function panelResizeUp () { _panelResize = null; document.removeEventListener('mousemove', panelResizeMove); document.removeEventListener('mouseup', panelResizeUp) }
// Étiquettes de GROUPE par adresse de service : une adresse avec 2 jobs = groupe lettré (A, B), rang interne (A1, A2). Lie liste pins.
function _normAddr (s) { return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, ' ').trim() }
function _colLetter (n) { let s = ''; n++; while (n > 0) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26) } return s }
function _jobLocKey (j) { const a = _normAddr(j.address); if (a) return a; return j.service_location ? 'sl:' + j.service_location : '' }
// Localisations des jobs géolocalisés : 1 LETTRE (A, B, C) par ADRESSE = 1 pin (couleur = compétence). Lie la liste aux pins.
const assignLocations = computed(() => {
const by = {}; const order = []
for (const j of assignPanel.jobs) {
if (!_hasJobLL(j)) continue
const k = (Math.round(+j.latitude * 1e5) / 1e5) + ',' + (Math.round(+j.longitude * 1e5) / 1e5)
if (!by[k]) { by[k] = { lat: +j.latitude, lon: +j.longitude, jobs: [] }; order.push(k) }
by[k].jobs.push(j)
}
const stops = []; const byName = {}
order.forEach((k, i) => {
const v = by[k]; const letter = _colLetter(i)
const skJob = v.jobs.find(j => j.required_skill || j.skill) || {}; const sk = skJob.required_skill || skJob.skill || ''
const j0 = v.jobs[0]
stops.push({ lat: v.lat, lon: v.lon, letter, color: sk ? getTagColor(sk) : '#90a4ae', count: v.jobs.length, names: v.jobs.map(j => j.name), title: (j0.address || j0.subject || '').slice(0, 60) })
for (const j of v.jobs) byName[j.name] = letter
})
return { stops, byName }
})
function groupLabel (j) { return assignLocations.value.byName[j.name] || '' } // lettre du pin de ce job (repère liste carte)
// 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
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 }
function toggleAssignMap () { assignPanel.showMap = !assignPanel.showMap }
function focusAssignJob (j) { if (_assignMap && _hasJobLL(j)) _assignMap.easeTo({ center: [+j.longitude, +j.latitude], zoom: 13, duration: 400 }) }
async function initAssignMap () {
if (!MAPBOX_TOKEN || !assignMapEl.value || _assignMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
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')
_assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
_assignMap.on('load', () => {
_assignMap.resize()
_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' } })
_assignMap.on('mouseenter', 'aj-c', () => { _assignMap.getCanvas().style.cursor = 'pointer' })
_assignMap.on('mouseleave', 'aj-c', () => { _assignMap.getCanvas().style.cursor = '' })
_assignMap.on('click', 'aj-c', (e) => {
const f = e.features[0]; const p = f.properties; const names = JSON.parse(p.names || '[]')
names.forEach(n => { selectedJobs[n] = true }) // clic sur le pin = sélectionne tous les jobs de cette adresse
new window.mapboxgl.Popup({ offset: 13 }).setLngLat(f.geometry.coordinates).setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.letter)}</b> · ${_esc(p.title)}<br>✓ ${p.count} job(s) sélectionné(s)<br><span style="color:#888">glisse-les sur un tech × jour</span></div>`).addTo(_assignMap)
})
refreshAssignMap()
})
}
function refreshAssignMap () {
if (!_assignMap || !_assignMap.isStyleLoaded()) { setTimeout(refreshAssignMap, 200); return }
const stops = assignStops(); const src = _assignMap.getSource('aj'); if (!src) return
src.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.letter + (s.count > 1 ? '·' + s.count : ''), color: s.color, count: s.count, names: JSON.stringify(s.names), letter: s.letter, title: s.title } })) })
if (stops.length === 1) _assignMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 11, duration: 400 })
else if (stops.length > 1) { const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]); stops.forEach(s => b.extend([s.lon, s.lat])); _assignMap.fitBounds(b, { padding: 40, maxZoom: 12, duration: 400 }) }
}
function destroyAssignMap () { if (_assignMapRO) { try { _assignMapRO.disconnect() } catch (e) {} _assignMapRO = null } if (_assignMap) { try { _assignMap.remove() } catch (e) {} _assignMap = null } }
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
watch(() => assignPanel.jobs, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) })
watch(() => assignPanel.open, (o) => { if (!o) destroyAssignMap() })
// Timeline contextuelle d'une RESSOURCE (dispatch des jobs de la semaine visible) // Timeline contextuelle d'une RESSOURCE (dispatch des jobs de la semaine visible)
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) 0 nouvel appel réseau. // Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) 0 nouvel appel réseau.
const timelineDlg = reactive({ open: false, tech: null }) const timelineDlg = reactive({ open: false, tech: null })
@ -1098,7 +1257,8 @@ const packedDay = computed(() => {
} }
return out return out
}) })
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) // Coords valides : non nulles, finies, ET pas ~0,0 (placeholder « pas de coords » golfe de Guinée).
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && (Math.abs(+j.lat) > 0.01 || Math.abs(+j.lon) > 0.01)
const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length) const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length)
// Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins // Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins
@ -1119,13 +1279,33 @@ const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
return packedDay.value.filter(hasLL).map((j, i) => ({ return packedDay.value.filter(hasLL).map((j, i) => ({
lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2', lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2',
subject: j.subject || '', customer: j.customer || '', time: fmtHM(j.startMin) + '' + fmtHM(j.endMin), subject: j.subject || '', customer: j.customer || '', address: j.address || '', time: fmtHM(j.startMin) + '' + fmtHM(j.endMin),
})) }))
} }
// Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline. // Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline.
const dayTravelSegs = () => { const p = packedDay.value; const out = []; for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out } const dayTravelSegs = () => { const p = packedDay.value; const out = []; for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
// Centre la carte sur un job (clic sur la ligne de la liste). // Centre la carte sur un job (clic sur la ligne de la liste).
function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) } function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) }
// Fil legacy (messages + réponses du ticket osTicket) chargé à la demande, mis en cache sur le job (j._thread).
async function loadThread (j) {
const id = j && (j.legacy_id || j.legacy_ticket_id); if (!id || j._thread) return
j._thread = { loading: true, messages: [] }
try { const r = await roster.ticketThread(id); j._thread = { loading: false, messages: r.messages || [], count: r.count || 0, status: r.status || '' } }
catch (e) { j._thread = { loading: false, error: true, messages: [] } }
}
function toggleJobDetail (j) { j.showDetail = !j.showDetail; if (j.showDetail) loadThread(j); focusDayJob(j) } // tournée : clic = déplie détails + fil + centre carte
function toggleAssignThread (j) { j._showThread = !j._showThread; if (j._showThread) loadThread(j) } // panneau d'assignation : icône fil
// Fermer un ticket inutile dans le legacy (depuis le panneau) sort du dispatch (Completed). Acteur = utilisateur Authentik.
async function closeLegacyTicket (j) {
if (!j || !j.legacy_ticket_id) return
if (!window.confirm('Fermer le ticket legacy #' + j.legacy_ticket_id + ' ?\n' + (j.subject || '') + '\n→ il sortira du dispatch (status « Completed »).')) return
try {
const r = await roster.closeLegacyTicket(j.legacy_ticket_id)
if (r && r.ok) { assignPanel.jobs = assignPanel.jobs.filter(x => x.name !== j.name); $q.notify({ type: 'positive', message: 'Ticket #' + j.legacy_ticket_id + ' fermé dans le legacy' }) }
else $q.notify({ type: 'warning', message: 'Échec fermeture : ' + ((r && r.error) || '?') })
} catch (e) { err(e) }
}
function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } }
async function initDayMap () { async function initDayMap () {
if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
@ -1147,7 +1327,7 @@ async function initDayMap () {
_dayMap.on('click', 'day-stops-c', (e) => { _dayMap.on('click', 'day-stops-c', (e) => {
const f = e.features[0]; const p = f.properties const f = e.features[0]; const p = f.properties
new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates) new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates)
.setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.label)}. ${_esc(p.subject)}</b><br>🕒 ${_esc(p.time)}${p.customer ? '<br>👤 ' + _esc(p.customer) : ''}</div>`) .setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.label)}. ${_esc(p.subject)}</b><br>🕒 ${_esc(p.time)}${p.customer ? '<br>👤 ' + _esc(p.customer) : ''}${p.address ? '<br>📍 ' + _esc(p.address) : ''}</div>`)
.addTo(_dayMap) .addTo(_dayMap)
}) })
refreshDayMap() refreshDayMap()
@ -1157,7 +1337,7 @@ function refreshDayMap () {
if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return } if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return }
const stops = dayStops() const stops = dayStops()
const sSrc = _dayMap.getSource('day-stops') const sSrc = _dayMap.getSource('day-stops')
if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.label, color: s.color } })) }) if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.label, color: s.color, subject: s.subject, customer: s.customer, address: s.address, time: s.time } })) })
if (stops.length === 1) _dayMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 13, duration: 400 }) if (stops.length === 1) _dayMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 13, duration: 400 })
else if (stops.length > 1) { else if (stops.length > 1) {
const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]) const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
@ -1464,6 +1644,8 @@ const shiftName = (s) => { const t = tplByName.value[s]; return t ? (t.template_
const covRows = computed(() => { const seen = {}; const rows = []; for (const c of coverageData.value) { const key = c.shift + '|' + c.zone; if (!seen[key]) { seen[key] = true; rows.push({ key, label: shiftName(c.shift) + ' · ' + c.zone }) } } return rows }) const covRows = computed(() => { const seen = {}; const rows = []; for (const c of coverageData.value) { const key = c.shift + '|' + c.zone; if (!seen[key]) { seen[key] = true; rows.push({ key, label: shiftName(c.shift) + ' · ' + c.zone }) } } return rows })
const covByKeyDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.shift + '|' + c.zone + '|' + c.date] = c; return m }) const covByKeyDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.shift + '|' + c.zone + '|' + c.date] = c; return m })
const gapByDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.date] = (m[c.date] || 0) + (c.shortfall || 0); return m }) const gapByDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.date] = (m[c.date] || 0) + (c.shortfall || 0); return m })
// Détail du manque de couverture d'un jour (par quart · zone) pour l'infobulle du badge rouge.
function gapDetail (iso) { return coverageData.value.filter(c => c.date === iso && c.shortfall > 0).map(c => `${shiftName(c.shift)} · ${c.zone || '—'} : ${c.assigned}/${c.required} (${c.shortfall})`).join('\n') }
function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] } function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] }
function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' } function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' }
function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } } function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } }
@ -1479,6 +1661,7 @@ function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn(
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() } function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() } function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) } function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
function navDay (dir) { guard(() => { start.value = addDaysISO(start.value, dir); loadWeek() }) } // décale la fenêtre d'UN jour (flèche simple)
function navToday () { guard(() => { start.value = thisMonday(); loadWeek() }) } function navToday () { guard(() => { start.value = thisMonday(); loadWeek() }) }
// chargement // chargement
@ -1529,7 +1712,6 @@ async function loadWeek () {
const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || [] const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || []
snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null
lastWeek.start = start.value; lastWeek.days = days.value lastWeek.start = start.value; lastWeek.days = days.value
const c = await roster.getCoverage(start.value, days.value); coverageData.value = c.coverage || []
await loadStats() await loadStats()
} catch (e) { err(e) } finally { loading.value = false } } catch (e) { err(e) } finally { loading.value = false }
} }
@ -1538,6 +1720,9 @@ async function loadStats () {
try { const o = await roster.getOccupancy(start.value, days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ } try { const o = await roster.getOccupancy(start.value, days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ }
try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ } try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ }
} }
// Refresh CIBLÉ + rapide de l'occupation (recharge les blocs/jobs des cellules timeline à jour) après une assignation,
// sans le loadWeek complet (assignations + couverture + stats), bien plus lent.
async function reloadOccupancy () { try { const o = await roster.getOccupancy(start.value, days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ } }
async function doGenerate () { async function doGenerate () {
generating.value = true generating.value = true
@ -1735,15 +1920,30 @@ function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept } function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept }
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) } function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) } function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
function openShiftMenu (t, d, ev, ti, di) {
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d; menu.target = ev.currentTarget
const wr = winOf(t.id, d.iso, false); quickEntry.value = ''
menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 }
menu.show = true
}
// Y a-t-il une bande timeline cliquable sur cette case ? (shift régulier OU garde, et pas absent/pause)
// Clic gauche sur le FOND de cellule (zone sans quart ni job) menu d'horaire (POSER / modifier un quart).
// Le clic sur la BANDE de quart/garde ou un BLOC de job ouvre la tournée (openDayFromCell, via @click.stop).
function onCellClick (t, d, ev, ti, di) { function onCellClick (t, d, ev, ti, di) {
if (justDragged.value) { justDragged.value = false; return } if (justDragged.value) { justDragged.value = false; return }
activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V
if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return } if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return }
if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return } if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return }
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d; menu.target = ev.currentTarget openShiftMenu(t, d, ev, ti, di)
const wr = winOf(t.id, d.iso, false); quickEntry.value = '' }
menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 } // Clic sur la BANDE de quart/garde ou un BLOC de job ouvre la tournée du jour. Guardé contre les fins de drag.
menu.show = true function openDayFromCell (t, d) { if (justDragged.value) { justDragged.value = false; return } activeCell.value = { id: t.id, name: t.name, iso: d.iso }; openDayEditor(t, d) }
// Clic DROIT sur une cellule menu d'horaire aussi (bonus / accessibilité).
function onCellContext (t, d, ev, ti, di) {
if (justDragged.value) { justDragged.value = false; return }
activeCell.value = { id: t.id, name: t.name, iso: d.iso }
openShiftMenu(t, d, ev, ti, di)
} }
function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti, ti); const t1 = Math.max(a.ti, ti); const d0 = Math.min(a.di, di); const d1 = Math.max(a.di, di); const add = []; for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) add.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso); selection.value = [...new Set([...selection.value, ...add])] } function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti, ti); const t1 = Math.max(a.ti, ti); const d0 = Math.min(a.di, di); const d1 = Math.max(a.di, di); const add = []; for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) add.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso); selection.value = [...new Set([...selection.value, ...add])] }
function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] } function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
@ -1835,7 +2035,7 @@ function onKey (e) {
} }
} }
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } } 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 */ } }) 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) */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) }) onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) })
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false }) onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
</script> </script>
@ -1849,7 +2049,16 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; } .assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; }
.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 { 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-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; } .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 { 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; }
.assign-chip-f { font-size: 10px; border: 1.5px solid #cfd8dc; border-radius: 11px; padding: 1px 8px; cursor: pointer; user-select: none; line-height: 1.6; background: #fff; color: #455a64; }
.assign-chip-f.clear { border-color: #b0bec5; color: #607d8b; font-weight: 700; }
.assign-chip-f:hover { filter: brightness(0.95); }
.assign-resize { position: absolute; right: 1px; bottom: 1px; width: 16px; height: 16px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 45%, #b0bec5 45%, #b0bec5 55%, transparent 55%, transparent 70%, #b0bec5 70%, #b0bec5 80%, transparent 80%); border-radius: 0 0 8px 0; z-index: 2; }
.assign-grp-badge { display: inline-block; background: #cfd8dc; color: #455a64; font-weight: 700; border-radius: 4px; padding: 0 5px; font-size: 10px; line-height: 16px; }
.assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; } .assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; }
.assign-grp-lbl { font-size: 11px; font-weight: 700; color: #37474f; padding: 3px 6px 2px; border-bottom: 1px solid #eee; margin-bottom: 2px; position: sticky; top: 0; background: #fff; z-index: 1; } .assign-grp-lbl { font-size: 11px; font-weight: 700; color: #37474f; padding: 3px 6px 2px; border-bottom: 1px solid #eee; margin-bottom: 2px; position: sticky; top: 0; background: #fff; z-index: 1; }
.assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; } /* groupe lié surligné dès qu'un membre est coché */ .assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; } /* groupe lié surligné dès qu'un membre est coché */
@ -1920,7 +2129,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; } .hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; } .hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; } .hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
.tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; } .tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; cursor: pointer; } /* fond cliquable → ajouter/modifier un quart */
.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */ .tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */
.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; } .tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; }
/* Éditeur de journée (clic progressbar) — lignes draggables */ /* Éditeur de journée (clic progressbar) — lignes draggables */
@ -1937,13 +2146,23 @@ tr.res-hidden .hide-eye { opacity: 1; }
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; } .de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; } .de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; }
.de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */ .de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */
.de-detail { font-size: 11px; line-height: 1.4; white-space: pre-wrap; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 160px; overflow: auto; } .de-detail { font-size: 11px; line-height: 1.4; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 280px; overflow: auto; }
.de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; }
.de-msg:first-of-type { border-top: none; }
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
.assign-msg:first-child { border-top: none; }
.assign-msg-txt { white-space: pre-wrap; color: #37474f; }
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */ .tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */
.tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */ .tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */ .tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = barre de statut opaque */ .tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = barre de statut opaque */
.tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */ .tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */
.tl-blk-click { cursor: pointer; } /* seuls les blocs de job ouvrent l'éditeur ; le reste de la cellule = édition d'horaire */ .tl-shift-click { cursor: pointer; } /* bande de quart/garde → clic ouvre la tournée (voir les jobs) */
.tl-shift-click:hover { filter: brightness(0.92); outline: 1px solid rgba(55,71,79,.4); outline-offset: -1px; }
.tl-blk-click { cursor: pointer; }
.tl-blk-click:hover { outline: 1px solid rgba(25,118,210,.7); outline-offset: -1px; filter: brightness(1.08); } .tl-blk-click:hover { outline: 1px solid rgba(25,118,210,.7); outline-offset: -1px; filter: brightness(1.08); }
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); } .tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); }
.occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); } .occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); }

View File

@ -0,0 +1,51 @@
-- backfill_dispatch_address.sql — remplit le champ `address` (adresse de service) des Dispatch Jobs
-- issus du pont legacy, qui ne l'écrivait jamais (175 jobs, address vide).
-- A) jobs AVEC service_location → adresse depuis la SL (address_line + ville, en ignorant 'N/A')
-- B) jobs SANS service_location → adresse extraite du sujet (partie après ' · ', l'ancien append du pont)
-- Le pont écrit désormais `address` directement (création + backfill update) ; ce script rattrape l'historique.
-- Idempotent (ne touche que address = '' / NULL). À lancer AVANT toute re-sync (qui nettoie le sujet).
\timing on
SELECT character_maximum_length AS address_maxlen FROM information_schema.columns
WHERE table_name = 'tabDispatch Job' AND column_name = 'address';
BEGIN;
-- A) depuis la Service Location liée
WITH a AS (
UPDATE "tabDispatch Job" dj
SET address = left(NULLIF(trim(both ', ' FROM concat_ws(', ', NULLIF(sl.address_line,''), NULLIF(sl.city,'N/A'))), ''), 140),
modified = NOW()
FROM "tabService Location" sl
WHERE dj.service_location = sl.name
AND coalesce(dj.legacy_ticket_id,'') <> ''
AND coalesce(dj.address,'') = ''
AND NULLIF(trim(both ', ' FROM concat_ws(', ', NULLIF(sl.address_line,''), NULLIF(sl.city,'N/A'))), '') IS NOT NULL
RETURNING 1
)
SELECT count(*) AS rempli_via_service_location FROM a;
-- B) depuis le sujet (jobs sans SL : le pont y avait collé l'adresse après ' · ')
WITH b AS (
UPDATE "tabDispatch Job" dj
SET address = left(trim(split_part(subject, ' · ', 2)), 140),
modified = NOW()
WHERE coalesce(dj.legacy_ticket_id,'') <> ''
AND coalesce(dj.address,'') = ''
AND position(' · ' IN subject) > 0
AND length(trim(split_part(subject, ' · ', 2))) > 0
RETURNING 1
)
SELECT count(*) AS rempli_via_sujet FROM b;
COMMIT;
-- État final
SELECT count(*) FILTER (WHERE coalesce(address,'') <> '') AS addr_ok,
count(*) FILTER (WHERE coalesce(address,'') = '') AS addr_vide,
count(*) AS total
FROM "tabDispatch Job" WHERE coalesce(legacy_ticket_id,'') <> '';
-- Reste-t-il des jobs ACTIFS (open/assigned) sans adresse ? (à diagnostiquer le cas échéant)
SELECT name, status, left(subject,70) AS subject, service_location
FROM "tabDispatch Job"
WHERE coalesce(legacy_ticket_id,'') <> '' AND coalesce(address,'') = ''
AND status IN ('open','assigned','in_progress')
ORDER BY modified DESC LIMIT 10;

View File

@ -0,0 +1,25 @@
-- diag_dispatch_addr.sql — état des adresses/coords des Dispatch Jobs issus du pont legacy.
\timing on
-- 1) Champs « adresse » présents sur le doctype Dispatch Job ?
SELECT column_name FROM information_schema.columns
WHERE table_name = 'tabDispatch Job'
AND (column_name ILIKE '%address%' OR column_name ILIKE '%location%'
OR column_name ILIKE '%civic%' OR column_name ILIKE '%street%'
OR column_name ILIKE '%zip%' OR column_name ILIKE '%postal%' OR column_name ILIKE '%city%')
ORDER BY 1;
-- 2) État des jobs issus du pont (legacy_ticket_id non vide)
SELECT
count(*) AS total_bridge,
count(*) FILTER (WHERE latitude IS NULL OR longitude IS NULL OR abs(coalesce(latitude,0)) < 1) AS sans_coords,
count(*) FILTER (WHERE coalesce(service_location,'') = '') AS sans_service_location,
count(*) FILTER (WHERE coalesce(service_location,'') = '' AND (latitude IS NULL OR abs(coalesce(latitude,0)) < 1)) AS ni_sl_ni_coords
FROM "tabDispatch Job"
WHERE coalesce(legacy_ticket_id,'') <> '';
-- 3) Échantillon de jobs sans coords (que voit-on dans le sujet ?)
SELECT name, legacy_ticket_id, left(subject, 80) AS subject, service_location,
round(latitude::numeric,4) AS lat, round(longitude::numeric,4) AS lon, status
FROM "tabDispatch Job"
WHERE coalesce(legacy_ticket_id,'') <> '' AND (latitude IS NULL OR abs(coalesce(latitude,0)) < 1)
ORDER BY modified DESC LIMIT 12;

View File

@ -0,0 +1,22 @@
-- diag_dispatch_addr2.sql — état du champ address + coords SL pour le job actif sans coords.
\timing on
-- 1) Remplissage du champ address sur les jobs du pont
SELECT count(*) FILTER (WHERE coalesce(address,'') = '') AS addr_vide,
count(*) FILTER (WHERE coalesce(address,'') <> '') AS addr_ok,
count(*) AS total
FROM "tabDispatch Job" WHERE coalesce(legacy_ticket_id,'') <> '';
-- 2) Le job actif sans coords (LEG-187195) : la SL liée a-t-elle des coords ?
SELECT sl.name, sl.address_line, sl.city,
round(sl.latitude::numeric,5) AS lat, round(sl.longitude::numeric,5) AS lon
FROM "tabService Location" sl WHERE sl.name = 'LOC-0000002656';
-- 3) Parmi les jobs sans coords : combien sont actifs (open/assigned) vs terminés ?
SELECT status, count(*) FROM "tabDispatch Job"
WHERE coalesce(legacy_ticket_id,'') <> '' AND (latitude IS NULL OR abs(coalesce(latitude,0)) < 1)
GROUP BY status ORDER BY 2 DESC;
-- 4) Parmi les jobs SANS service_location : combien actifs ? (ce sont eux qui perdent l'adresse)
SELECT status, count(*) FROM "tabDispatch Job"
WHERE coalesce(legacy_ticket_id,'') <> '' AND coalesce(service_location,'') = ''
GROUP BY status ORDER BY 2 DESC;

View File

@ -0,0 +1,91 @@
<?php
/**
* ops_reassign.php Pont d'écriture Ops legacy osTicket (gestionclient).
* Déployé SUR facturation.targo.ca (hôte de l'app) → la connexion DB hérite des droits d'ÉCRITURE
* 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=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,
* dates en epoch (time()), réouverture des tickets enfants `waiting_for` à la fermeture.
*
* ⚠️ Secrets (TOKEN + creds DB) : remplis UNIQUEMENT dans la copie serveur, jamais committés. Ici = placeholders.
*/
header('Content-Type: application/json; charset=utf-8');
// ─── à remplir sur le serveur (hors repo) ───
$OPS_TOKEN = '__OPS_TOKEN__';
$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)
function out($a, $code = 200) { http_response_code($code); echo json_encode($a, JSON_UNESCAPED_UNICODE); exit; }
// ─── auth ───
$tok = $_SERVER['HTTP_X_OPS_TOKEN'] ?? ($_POST['token'] ?? '');
if (!is_string($tok) || !hash_equals($OPS_TOKEN, $tok)) out(['ok' => false, 'error' => 'forbidden'], 403);
$action = $_POST['action'] ?? 'reassign';
$ticket = (int)($_POST['ticket_id'] ?? 0);
if ($ticket <= 0) out(['ok' => false, 'error' => 'ticket_id requis'], 400);
// Auteur réel de l'action = staff legacy du répartiteur Ops (mappé depuis son email Authentik côté hub) ; repli = Tech Targo.
$actor = (int)($_POST['actor_staff_id'] ?? 0);
$logStaff = $actor > 0 ? $actor : $OPS_STAFF;
// ─── connexion (droits write hérités de l'hôte app) ───
$db = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
if ($db->connect_errno) out(['ok' => false, 'error' => 'db_connect: ' . $db->connect_error], 500);
$db->set_charset('utf8');
// ─── ticket courant ───
$res = $db->query("SELECT id, status, assign_to, lock_name, followed_by FROM ticket WHERE id = $ticket LIMIT 1");
$t = $res ? $res->fetch_assoc() : null;
if (!$t) out(['ok' => false, 'error' => 'ticket introuvable'], 404);
if ($t['status'] === 'closed') out(['ok' => false, 'error' => 'ticket déjà fermé', 'status' => 'closed']);
// LOCK : un staff édite le ticket dans le legacy → on NE clobbe PAS (le répartiteur réessaie)
if (trim((string)$t['lock_name']) !== '') out(['ok' => false, 'error' => 'verrouillé', 'locked_by' => $t['lock_name']]);
function ops_log($db, $ticket, $staff, $msg) {
$m = $db->real_escape_string('<hr><p>CHANGE LOG (Ops):</p><p>' . $msg . '</p>');
$db->query("INSERT INTO ticket_msg (ticket_id, staff_id, msg, date_orig, public) VALUES ($ticket, $staff, '$m', " . time() . ", 0)");
}
$now = time();
if ($action === 'close') {
$db->query("UPDATE ticket SET status='closed', date_closed=$now, closed_by=$logStaff, last_update=$now WHERE id=$ticket AND status<>'closed'");
$aff = $db->affected_rows;
// réouvre les tickets enfants en attente de celui-ci (fidèle au legacy)
$db->query("UPDATE ticket SET status='open', last_update=$now WHERE waiting_for=$ticket AND status<>'closed'");
ops_log($db, $ticket, $logStaff, 'Ticket fermé via Ops.');
out(['ok' => true, 'action' => 'close', 'affected' => $aff]);
}
// ─── 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");
$s = $chk ? $chk->fetch_assoc() : null;
if (!$s) out(['ok' => false, 'error' => 'staff inactif ou introuvable', 'staff_id' => $staff]);
// assistants : CSV d'ids → participant `-id-;-id-` + ajout aux followers (followed_by JSON) — FIDÈLE à ticket_view.php
$assistCsv = trim((string)($_POST['assist_ids'] ?? ''));
$assistIds = $assistCsv === '' ? [] : array_values(array_unique(array_filter(array_map('intval', explode(',', $assistCsv)))));
$participant = '';
foreach ($assistIds as $a) { $participant .= ($participant === '' ? '' : ';') . "-$a-"; }
$aFollow = json_decode($t['followed_by'] !== '' && $t['followed_by'] !== null ? $t['followed_by'] : '{}', true);
if (!is_array($aFollow)) $aFollow = [];
foreach ($assistIds as $a) { if (!array_key_exists((string)$a, $aFollow)) $aFollow[(string)$a] = ['child' => 0]; }
$jsonFollow = $db->real_escape_string(json_encode((object)$aFollow));
$participantEsc = $db->real_escape_string($participant);
$db->query("UPDATE ticket SET assign_to=$staff, participant='$participantEsc', followed_by='$jsonFollow', last_update=$now WHERE id=$ticket AND status<>'closed'");
$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]);

View File

@ -26,6 +26,22 @@ const cfg = require('./config')
const { log, json, httpRequest } = require('./helpers') const { log, json, httpRequest } = require('./helpers')
const { searchAddressesRpc } = require('./address-search') // recherche trigram RQA (RPC pg_trgm) — celle de l'autocomplete de dispo 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 addrdb = require('./address-db') // pool PG local (camping_registry)
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.
const OPS_LEGACY_URL = process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php'
function opsLegacyToken () { try { return (process.env.OPS_LEGACY_TOKEN || fs.readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch (e) { return (process.env.OPS_LEGACY_TOKEN || '').trim() } }
async function legacyWrite (fields) { // POST form-urlencoded + X-Ops-Token → ops_reassign.php
const body = Object.entries(fields).map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v == null ? '' : v)).join('&')
return httpRequest(OPS_LEGACY_URL, '', { method: 'POST', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Ops-Token': opsLegacyToken() }, timeout: 15000 })
}
// Ferme un ticket dans le legacy (via PHP : status=closed + date_closed + closed_by=acteur + réouverture enfants + log).
async function closeTicketLegacy (ticketId, actorEmail) {
let actorStaff = 0
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]) actorStaff = ar[0].id } } catch (e) {}
const w = await legacyWrite({ action: 'close', ticket_id: ticketId, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
return (w && w.data) || { ok: false, error: 'no response' }
}
// Campings : l'adresse de service est un terrain de camping (≠ résidence du client). On force la géoloc // 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 // FIXE du camping (registre camping_registry). Détection robuste : le texte doit contenir « camping » OU
@ -66,6 +82,37 @@ const { norm } = require('./util/text')
// GARDE-FOU de zone : le civique doit concorder ET le CP OU la ville doit confirmer la région → rejette // GARDE-FOU de zone : le civique doit concorder ET le CP OU la ville doit confirmer la région → rejette
// les faux positifs trigram hors-territoire (ex. « Rue Grenet, Montréal » quand un civique René-Vinet // les faux positifs trigram hors-territoire (ex. « Rue Grenet, Montréal » quand un civique René-Vinet
// absent du RQA déclenche la phase 2). Cache module (1 appel/adresse/vie ; échecs mémorisés). // absent du RQA déclenche la phase 2). Cache module (1 appel/adresse/vie ; échecs mémorisés).
// TERRITOIRE de service TARGO (Montérégie sud-ouest : Haut-St-Laurent, Beauharnois-Salaberry, Jardins-de-Napierville,
// Roussillon). Rejette les géocodes hors-zone — Mapbox/trigram tombent sinon sur des HOMONYMES (Gaspésie, Outaouais,
// Estrie : « Saint-Louis », « Franklin », « Lac des Pins »…). bbox volontairement large mais bien en deçà du reste du QC.
const TERR = { latMin: 44.85, latMax: 45.6, lonMin: -74.85, lonMax: -73.2 }
function inTerritory (lat, lon) { const a = parseFloat(lat), o = parseFloat(lon); return isFinite(a) && isFinite(o) && a >= TERR.latMin && a <= TERR.latMax && o >= TERR.lonMin && o <= TERR.lonMax }
// Repli CENTROÏDE (code postal complet, sinon ville) depuis rqa_addresses LOCAL — « au pire, le centre du CP/ville ».
// Toujours EN territoire (filtré par bbox). Cache module.
const _centroidCache = new Map()
async function geocodeCentroid (postalCode, city) {
const fsa = String(postalCode || '').replace(/\s+/g, '').toUpperCase()
const key = 'ctr|' + fsa + '|' + norm(city)
if (_centroidCache.has(key)) return _centroidCache.get(key)
let res = null
try {
const pg = addrdb.pool()
if (fsa.length >= 6) { // centroïde du code postal complet (zone fine, ex. J0S1T0)
const r = await pg.query("SELECT avg(latitude) AS lat, avg(longitude) AS lon, count(*) AS n FROM rqa_addresses WHERE replace(upper(code_postal), ' ', '') = $1", [fsa])
const row = r.rows[0]
if (row && Number(row.n) > 0 && inTerritory(row.lat, row.lon)) res = { lat: +row.lat, lon: +row.lon }
}
if (!res && city) { // sinon centroïde de la VILLE, contraint au territoire
const r = await pg.query("SELECT avg(latitude) AS lat, avg(longitude) AS lon, count(*) AS n FROM rqa_addresses WHERE lower(unaccent(ville)) = lower(unaccent($1)) AND latitude BETWEEN $2 AND $3 AND longitude BETWEEN $4 AND $5", [city, TERR.latMin, TERR.latMax, TERR.lonMin, TERR.lonMax])
const row = r.rows[0]
if (row && Number(row.n) > 0) res = { lat: +row.lat, lon: +row.lon }
}
} catch (e) { log('geocodeCentroid error:', e.message) }
_centroidCache.set(key, res)
return res
}
const _geoCache = new Map() const _geoCache = new Map()
async function geocodeRQA (addressLine, postalCode, city) { async function geocodeRQA (addressLine, postalCode, city) {
const key = norm([addressLine, postalCode, city].filter(Boolean).join('|')) const key = norm([addressLine, postalCode, city].filter(Boolean).join('|'))
@ -82,7 +129,7 @@ async function geocodeRQA (addressLine, postalCode, city) {
const streetToks = norm(addressLine).replace(/^\s*\d+\s*/, '').split(/[\s-]+/).filter(w => w.length >= 3 && !GEN.includes(w)) // tokens significatifs du nom de rue const streetToks = norm(addressLine).replace(/^\s*\d+\s*/, '').split(/[\s-]+/).filter(w => w.length >= 3 && !GEN.includes(w)) // tokens significatifs du nom de rue
const streetOk = (r) => { if (!streetToks.length) return true; const hay = norm((r.odonyme_recompose_normal || '') + ' ' + (r.adresse_formatee || '')); return streetToks.some(w => hay.includes(w)) } const streetOk = (r) => { if (!streetToks.length) return true; const hay = norm((r.odonyme_recompose_normal || '') + ' ' + (r.adresse_formatee || '')); return streetToks.some(w => hay.includes(w)) }
const pick = rows.find(r => { const pick = rows.find(r => {
if (!coord(r.latitude, r.longitude)) return false if (!coord(r.latitude, r.longitude) || !inTerritory(r.latitude, r.longitude)) return false // hors territoire = homonyme → rejet
if (civic && String(r.numero_municipal || '') !== civic) return false // mauvais numéro civique → rejet if (civic && String(r.numero_municipal || '') !== civic) return false // mauvais numéro civique → rejet
if (!streetOk(r)) return false // bon civique mais mauvaise rue (faux positif trigram) → rejet if (!streetOk(r)) return false // bon civique mais mauvaise rue (faux positif trigram) → rejet
const rFsa = String(r.code_postal || '').replace(/\s+/g, '').toUpperCase().slice(0, 3) const rFsa = String(r.code_postal || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
@ -122,7 +169,7 @@ async function geocodeMapbox (addressLine, city, postalCode) {
const f = r && r.data && Array.isArray(r.data.features) && r.data.features[0] const f = r && r.data && Array.isArray(r.data.features) && r.data.features[0]
if (f && Array.isArray(f.center) && (f.relevance == null || f.relevance >= 0.6)) { if (f && Array.isArray(f.center) && (f.relevance == null || f.relevance >= 0.6)) {
const c = coord(f.center[1], f.center[0]) // Mapbox = [lon, lat] const c = coord(f.center[1], f.center[0]) // Mapbox = [lon, lat]
if (c) res = c if (c && inTerritory(c.lat, c.lon)) res = c // hors territoire (proximité insuffisante → homonyme lointain) → rejet
} }
} catch (e) { log('geocodeMapbox error:', e.message) } } catch (e) { log('geocodeMapbox error:', e.message) }
_mbCache.set(key, res) _mbCache.set(key, res)
@ -153,6 +200,9 @@ function stripHtml (html, max = 1500) {
.replace(/<[^>]+>/g, '') .replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>') .replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/&#0*39;|&#x27;|&apos;/gi, "'").replace(/&quot;/gi, '"') .replace(/&#0*39;|&#x27;|&apos;/gi, "'").replace(/&quot;/gi, '"')
.replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)) } catch (e) { return '' } })
.replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(parseInt(n, 10)) } catch (e) { return '' } })
.replace(/&(eacute|egrave|ecirc|euml|agrave|acirc|aacute|ccedil|ocirc|ouml|ugrave|ucirc|icirc|iuml|ntilde|deg|laquo|raquo|hellip|rsquo|lsquo|ldquo|rdquo|ndash|mdash|nbsp|euro);/gi, (m, e) => ({ eacute: 'é', egrave: 'è', ecirc: 'ê', euml: 'ë', agrave: 'à', acirc: 'â', aacute: 'á', ccedil: 'ç', ocirc: 'ô', ouml: 'ö', ugrave: 'ù', ucirc: 'û', icirc: 'î', iuml: 'ï', ntilde: 'ñ', deg: '°', laquo: '«', raquo: '»', hellip: '…', rsquo: '', lsquo: '', ldquo: '“', rdquo: '”', ndash: '', mdash: '—', nbsp: ' ', euro: '€' }[e.toLowerCase()] || m))
.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim() .replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
if (s.length > max) s = s.slice(0, max) + '…' if (s.length > max) s = s.slice(0, max) + '…'
return s return s
@ -225,8 +275,8 @@ async function buildJob (t) {
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ') const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
const addr = svcAddr || billAddr const addr = svcAddr || billAddr
let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — ')) let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
if (!sl && addr) subject = (subject + ' · ' + addr) // pas de Service Location → on garde l'adresse visible dans le sujet const idTag = ' · #' + t.id // n° de ticket legacy visible dans le TITRE (référence croisée osTicket) — réserve la place pour ne pas le tronquer
subject = subject.slice(0, 140) // Subject = champ Data Frappe (max 140 car.) ; le détail complet est dans legacy_detail/coords subject = (subject.length + idTag.length > 140 ? subject.slice(0, 140 - idTag.length) : subject) + idTag
const payload = { const payload = {
ticket_id: 'LEG-' + t.id, ticket_id: 'LEG-' + t.id,
@ -239,9 +289,10 @@ async function buildJob (t) {
legacy_ticket_id: String(t.id), legacy_ticket_id: String(t.id),
legacy_dept: t.dept || '', // département legacy granulaire → coloriage « comme legacy » (Installation Fibre / Réparation Fibre / Télé / Téléphonie…) legacy_dept: t.dept || '', // département legacy granulaire → coloriage « comme legacy » (Installation Fibre / Réparation Fibre / Télé / Téléphonie…)
} }
if (addr) payload.address = addr.slice(0, 140) // adresse de SERVICE (delivery, sinon facturation) EN CLAIR — toujours importée, indépendamment du lien Service Location / des coords (champ Frappe Data = 140 car.)
const actUrl = extractActivationUrl(t.activation_msg); if (actUrl) payload.legacy_activation_url = actUrl // lien connect_ministra (déjà dans le fil) const actUrl = extractActivationUrl(t.activation_msg); if (actUrl) payload.legacy_activation_url = actUrl // lien connect_ministra (déjà dans le fil)
// En-tête de dates (ouverture + dernière MàJ) pour juger l'ancienneté → décider de fermer ; puis la description. // En-tête de dates (ouverture + dernière MàJ) pour juger l'ancienneté → décider de fermer ; puis la description.
const dateHdr = '🗓 Ouvert ' + (tzDate(t.date_create) || '?') + (t.last_update ? ' · MàJ ' + tzDate(t.last_update) : '') const dateHdr = '🎫 Ticket #' + t.id + ' · 🗓 Ouvert ' + (tzDate(t.date_create) || '?') + (t.last_update ? ' · MàJ ' + tzDate(t.last_update) : '')
const detail = [dateHdr, stripHtml(t.first_msg)].filter(Boolean).join('\n\n') const detail = [dateHdr, stripHtml(t.first_msg)].filter(Boolean).join('\n\n')
if (detail) payload.legacy_detail = detail // description + dates → visible dans Ops (mouseover panneau + détail Dispatch) if (detail) payload.legacy_detail = detail // description + dates → visible dans Ops (mouseover panneau + détail Dispatch)
const sd = tzDate(t.due_date); if (sd) payload.scheduled_date = sd const sd = tzDate(t.due_date); if (sd) payload.scheduled_date = sd
@ -270,7 +321,7 @@ async function buildJob (t) {
} }
async function findExisting (legacyId) { async function findExisting (legacyId) {
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location'], limit: 1 }) const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location', 'address'], limit: 1 })
return (r && r[0]) || null return (r && r[0]) || null
} }
@ -316,6 +367,8 @@ async function syncImpl ({ dryRun = false } = {}) {
(Math.abs(parseFloat(ex.latitude) - b.payload.latitude) > 1e-5 || Math.abs(parseFloat(ex.longitude) - b.payload.longitude) > 1e-5) (Math.abs(parseFloat(ex.latitude) - b.payload.latitude) > 1e-5 || Math.abs(parseFloat(ex.longitude) - b.payload.longitude) > 1e-5)
if (b.payload.latitude != null && (!exHas || isUpgrade)) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude; coordsFilled++ } if (b.payload.latitude != null && (!exHas || isUpgrade)) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude; coordsFilled++ }
if (!ex.service_location && b.payload.service_location) patch.service_location = b.payload.service_location // backfill lien Service Location if (!ex.service_location && b.payload.service_location) patch.service_location = b.payload.service_location // backfill lien Service Location
// Adresse de SERVICE : backfill si jamais écrite (cas des 175 jobs historiques) ; et rafraîchit pour les jobs encore au pool (open + non assigné).
if (b.payload.address && (!ex.address || (ex.status === 'open' && !ex.assigned_tech && ex.address !== b.payload.address))) patch.address = b.payload.address
if (ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) patch.scheduled_date = b.payload.scheduled_date if (ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
// Rafraîchit le sujet (qui inclut l'adresse de SERVICE) pour les jobs encore au pool (open + non assigné), // Rafraîchit le sujet (qui inclut l'adresse de SERVICE) pour les jobs encore au pool (open + non assigné),
// sans surprendre un tech sur un job déjà dispatché. Corrige les sujets anciens basés sur la facturation. // sans surprendre un tech sur un job déjà dispatché. Corrige les sujets anciens basés sur la facturation.
@ -358,6 +411,239 @@ async function reconcile () {
return { ok: true, legacy_open_3301: legacyIds.size, erpnext_bridged: erpIds.size, missing_count: missing.length, missing, orphan_count: orphan.length, orphan, last_sync: _lastRun } return { ok: true, legacy_open_3301: legacyIds.size, erpnext_bridged: erpIds.size, missing_count: missing.length, missing, orphan_count: orphan.length, orphan, last_sync: _lastRun }
} }
// RÉIMPORT autoritaire des adresses de SERVICE depuis la table legacy `delivery` (point de service réel),
// pour TOUS les Dispatch Jobs issus du pont — y compris assignés/fermés que la sync (open + 3301) ne retouche pas.
// Écrit `address` (svcAddr delivery > billAddr) et remplit les coords MANQUANTES (0,0/NULL) depuis delivery
// (ne clobbe JAMAIS un job déjà géolocalisé → sécurité dispatch). Les jobs de CAMPING sont exclus : la `delivery`
// legacy y pointe la RÉSIDENCE du client (ce qu'on corrige justement par la géoloc fixe du camping).
// Passe par le verrou de sync (frappe_pg = zéro concurrence). dryRun => 0 écriture + échantillon des changements.
function reimportAddresses (opts = {}) {
const run = _syncLock.then(() => reimportAddressesImpl(opts), () => reimportAddressesImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
async function reimportAddressesImpl ({ dryRun = false, overwrite = true } = {}) {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', '']], fields: ['name', 'legacy_ticket_id', 'subject', 'address', 'latitude', 'longitude', 'status', 'assigned_tech'], limit: 5000 })
const ids = [...new Set((jobs || []).map(j => parseInt(j.legacy_ticket_id, 10)).filter(Number.isFinite))]
if (!ids.length) return { ok: true, dryRun, jobs: 0, legacy_found: 0, address_updated: 0, coords_filled: 0, camping_skipped: 0, skipped: 0, errors: 0, samples: [] }
// Même jointure `delivery` que la sync (delivery_id du ticket → repli delivery avec coords → n'importe quel delivery du compte).
const [rows] = await p.query(
`SELECT t.id,
dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip, dv.latitude AS dv_lat, dv.longitude AS dv_lon,
a.address1, a.address2, a.city AS a_city, a.state, a.zip AS a_zip
FROM ticket t
LEFT JOIN account a ON a.id = t.account_id
LEFT JOIN delivery dv ON dv.id = COALESCE(
NULLIF(t.delivery_id, 0),
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
)
WHERE t.id IN (?)`,
[ids],
)
const byId = new Map((rows || []).map(r => [String(r.id), r]))
const campings = await getCampings()
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
let addrUpd = 0, coordFill = 0, skipped = 0, campSkip = 0, errors = 0
const samples = []
for (const j of jobs) {
const r = byId.get(String(j.legacy_ticket_id)); if (!r) { skipped++; continue }
if (campingFor(campings, [j.subject, r.dv_city, r.dv_addr])) { campSkip++; continue } // camping → résidence non pertinente, on garde la géoloc camping
const svcAddr = [r.dv_addr, r.dv_city, r.dv_zip].filter(Boolean).join(', ')
const billAddr = [r.address1, r.address2, r.a_city, r.state, r.a_zip].filter(Boolean).join(', ')
const addr = (svcAddr || billAddr).slice(0, 140)
const patch = {}
if (addr && (overwrite ? norm(addr) !== norm(j.address || '') : !j.address)) patch.address = addr
const dc = coord(r.dv_lat, r.dv_lon) // coords delivery valides (bornes QC) ?
if (dc && !(hasCoord(j.latitude) && hasCoord(j.longitude))) { patch.latitude = dc.lat; patch.longitude = dc.lon } // remplit SEULEMENT si manquantes
if (!Object.keys(patch).length) { skipped++; continue }
if (patch.address) addrUpd++
if (patch.latitude != null) coordFill++
if (samples.length < 15) samples.push({ job: j.name, ticket: j.legacy_ticket_id, status: j.status, src: svcAddr ? 'delivery' : 'billing', from: (j.address || '').slice(0, 45), to: patch.address || '(adresse inchangée)', coords_filled: patch.latitude != null })
if (!dryRun) { const res = await erp.update('Dispatch Job', j.name, patch); if (!(res && res.ok)) errors++ }
}
return { ok: true, dryRun, jobs: jobs.length, legacy_found: rows.length, address_updated: addrUpd, coords_filled: coordFill, camping_skipped: campSkip, skipped, errors, samples }
}
// REMPLIT les coords MANQUANTES (0,0/NULL) des Dispatch Jobs qui ont une adresse mais pas de GPS → « hors carte ».
// Priorité : coords de la Service Location liée (si valides) → géocodage RQA (trigram) → Mapbox. Ne clobbe JAMAIS
// un job déjà géolocalisé. Couvre TOUTE source (legacy ET jobs de test/seed). Verrou de sync. dryRun => 0 écriture.
function fillMissingCoords (opts = {}) {
const run = _syncLock.then(() => fillMissingCoordsImpl(opts), () => fillMissingCoordsImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
const PC_RE = /[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d/ // code postal canadien
function splitAddr (address) { // "ligne, ville, code postal" → { line, city, postal } (tolérant)
const parts = String(address || '').split(',').map(s => s.trim()).filter(Boolean)
const line = parts[0] || ''
const last = parts[parts.length - 1] || ''
const postal = PC_RE.test(last) ? last : ''
const city = postal ? (parts[parts.length - 2] || '') : (parts.length > 1 ? parts[parts.length - 1] : '')
return { line, city, postal }
}
async function fillMissingCoordsImpl ({ dryRun = false } = {}) {
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
const jobs = await erp.list('Dispatch Job', { filters: [['address', '!=', '']], fields: ['name', 'address', 'latitude', 'longitude', 'status', 'service_location'], limit: 5000 })
const targets = (jobs || []).filter(j => !(hasCoord(j.latitude) && hasCoord(j.longitude)))
if (!targets.length) return { ok: true, dryRun, candidates: 0, filled: 0, via_service_location: 0, via_rqa: 0, via_mapbox: 0, failed: 0, samples: [] }
// Précharge les coords des Service Locations liées (1 requête).
const slCodes = [...new Set(targets.map(j => j.service_location).filter(Boolean))]
const slCoord = new Map()
if (slCodes.length) {
const sls = await erp.list('Service Location', { filters: [['name', 'in', slCodes]], fields: ['name', 'latitude', 'longitude'], limit: slCodes.length + 10 })
for (const s of (sls || [])) if (hasCoord(s.latitude) && hasCoord(s.longitude)) slCoord.set(s.name, { lat: Number(s.latitude), lon: Number(s.longitude) })
}
let filled = 0, viaSL = 0, viaRQA = 0, viaMB = 0, failed = 0
const samples = []
for (const j of targets) {
let c = null, src = null
if (j.service_location && slCoord.has(j.service_location)) { c = slCoord.get(j.service_location); src = 'service_location' }
if (!c) { const { line, city, postal } = splitAddr(j.address); const g = await geocodeRQA(line, postal, city); if (g) { c = g; src = 'rqa' } }
if (!c) { const { line, city, postal } = splitAddr(j.address); const mb = await geocodeMapbox(line, city, postal); if (mb) { c = mb; src = 'mapbox' } }
if (!c) { failed++; if (samples.length < 15) samples.push({ job: j.name, address: (j.address || '').slice(0, 50), result: 'ÉCHEC géocodage' }); continue }
if (samples.length < 15) samples.push({ job: j.name, address: (j.address || '').slice(0, 50), result: `${c.lat.toFixed(5)},${c.lon.toFixed(5)} via ${src}` })
if (!dryRun) { const r = await erp.update('Dispatch Job', j.name, { latitude: c.lat, longitude: c.lon }); if (!(r && r.ok)) { failed++; continue } }
filled++; if (src === 'service_location') viaSL++; else if (src === 'rqa') viaRQA++; else viaMB++
}
return { ok: true, dryRun, candidates: targets.length, filled, via_service_location: viaSL, via_rqa: viaRQA, via_mapbox: viaMB, failed, samples }
}
// CORRIGE les coords MANQUANTES *ou HORS-TERRITOIRE* (homonymes mal géocodés : Gaspésie/Outaouais/Estrie).
// Chaîne TOUT-EN-TERRITOIRE : camping → coords Service Location → RQA rue (gardé territoire) → centroïde code
// postal → centroïde ville. ÉCRASE une coord hors-zone (c'est le bug) ; remplit une coord manquante. Verrou de sync.
function fixGeocoding (opts = {}) {
const run = _syncLock.then(() => fixGeocodingImpl(opts), () => fixGeocodingImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
async function fixGeocodingImpl ({ dryRun = false } = {}) {
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
const jobs = await erp.list('Dispatch Job', { filters: [['address', '!=', '']], fields: ['name', 'subject', 'address', 'latitude', 'longitude', 'status', 'service_location'], limit: 5000 })
// cibles = pas de coords valides EN territoire (manquantes OU hors-zone)
const targets = (jobs || []).filter(j => !(hasCoord(j.latitude) && hasCoord(j.longitude) && inTerritory(j.latitude, j.longitude)))
if (!targets.length) return { ok: true, dryRun, candidates: 0, fixed: 0, via_camping: 0, via_service_location: 0, via_rqa: 0, via_postal: 0, via_city: 0, failed: 0, samples: [] }
const campings = await getCampings()
const slCodes = [...new Set(targets.map(j => j.service_location).filter(Boolean))]
const slCoord = new Map()
if (slCodes.length) {
const sls = await erp.list('Service Location', { filters: [['name', 'in', slCodes]], fields: ['name', 'latitude', 'longitude'], limit: slCodes.length + 10 })
for (const s of (sls || [])) if (hasCoord(s.latitude) && hasCoord(s.longitude) && inTerritory(s.latitude, s.longitude)) slCoord.set(s.name, { lat: Number(s.latitude), lon: Number(s.longitude) })
}
let fixed = 0, viaCamp = 0, viaSL = 0, viaRQA = 0, viaPostal = 0, viaCity = 0, failed = 0
const samples = []
for (const j of targets) {
const { line, city, postal } = splitAddr(j.address)
let c = null, src = null
const camp = campingFor(campings, [j.subject, j.address])
if (camp && inTerritory(camp.latitude, camp.longitude)) { c = { lat: Number(camp.latitude), lon: Number(camp.longitude) }; src = 'camping' }
if (!c && j.service_location && slCoord.has(j.service_location)) { c = slCoord.get(j.service_location); src = 'service_location' }
if (!c) { const g = await geocodeRQA(line, postal, city); if (g) { c = g; src = 'rqa' } } // déjà gardé territoire
if (!c) { const ce = await geocodeCentroid(postal, city); if (ce) { c = ce; src = (String(postal || '').replace(/\s+/g, '').length >= 6) ? 'postal' : 'city' } }
if (!c) { failed++; if (samples.length < 20) samples.push({ job: j.name, address: (j.address || '').slice(0, 46), result: 'ÉCHEC (absent RQA)' }); continue }
if (samples.length < 20) samples.push({ job: j.name, address: (j.address || '').slice(0, 46), from: `${(+j.latitude || 0).toFixed(3)},${(+j.longitude || 0).toFixed(3)}`, to: `${c.lat.toFixed(4)},${c.lon.toFixed(4)} via ${src}` })
if (!dryRun) { const r = await erp.update('Dispatch Job', j.name, { latitude: c.lat, longitude: c.lon }); if (!(r && r.ok)) { failed++; continue } }
fixed++; if (src === 'camping') viaCamp++; else if (src === 'service_location') viaSL++; else if (src === 'rqa') viaRQA++; else if (src === 'postal') viaPostal++; else viaCity++
}
return { ok: true, dryRun, candidates: targets.length, fixed, via_camping: viaCamp, via_service_location: viaSL, via_rqa: viaRQA, via_postal: viaPostal, via_city: viaCity, failed, samples }
}
// PURGE des orphelins d'un ANCIEN import : jobs nommés `TT-<id>` SANS legacy_ticket_id dont le ticket legacy est
// `closed` (doublons/périmés — adresse réduite à la ville ; closeResolved ne peut PAS les voir faute de
// legacy_ticket_id). Supprime via l'API Frappe (nettoie les liens). NE touche JAMAIS un ticket encore `open`.
// Le record canonique reste côté legacy + les jobs `LEG-`. Verrou de sync. dryRun => 0 écriture.
function purgeStaleOrphans (opts = {}) {
const run = _syncLock.then(() => purgeStaleOrphansImpl(opts), () => purgeStaleOrphansImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
async function purgeStaleOrphansImpl ({ dryRun = false } = {}) {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const jobs = await erp.list('Dispatch Job', { filters: [['name', 'like', 'TT-%']], fields: ['name', 'status', 'subject', 'legacy_ticket_id'], limit: 500 })
const withId = (jobs || [])
.filter(j => !j.legacy_ticket_id) // seulement les orphelins (les jobs du pont actuel ont un legacy_ticket_id → intouchables)
.map(j => ({ ...j, tid: (String(j.name).match(/^TT-(\d+)$/) || [])[1] }))
.filter(j => j.tid)
if (!withId.length) return { ok: true, dryRun, candidates: 0, closed: 0, deleted: 0, kept_open: 0, kept_open_detail: [], errors: 0, samples: [] }
const ids = [...new Set(withId.map(j => parseInt(j.tid, 10)))]
const [rows] = await p.query('SELECT id, status FROM ticket WHERE id IN (?)', [ids])
const st = {}; for (const r of rows) st[String(r.id)] = r.status
const stale = withId.filter(j => st[j.tid] === 'closed') // périmés = ticket legacy fermé
const keptOpen = withId.filter(j => st[j.tid] !== 'closed').map(j => ({ name: j.name, legacy_status: st[j.tid] || 'introuvable' }))
let deleted = 0, errors = 0
const samples = []
for (const j of stale) {
if (samples.length < 25) samples.push({ name: j.name, subject: (j.subject || '').slice(0, 40) })
if (!dryRun) { const r = await erp.remove('Dispatch Job', j.name); if (r && r.ok) deleted++; else errors++ }
}
return { ok: true, dryRun, candidates: withId.length, closed: stale.length, deleted, kept_open: keptOpen.length, kept_open_detail: keptOpen, errors, samples }
}
// WRITE-BACK vers le legacy osTicket : pousse l'assignation tech décidée dans Ops → `ticket.assign_to`.
// Mapping TECH-<id> → staff legacy <id>, VALIDÉ par concordance de NOM (le staff legacy doit porter le même nom
// que le tech Ops → évite de pousser un tech de test/système mal mappé). Ne touche QUE les tickets encore `open`.
// Idempotent (saute si assign_to est déjà le bon). C'est un WRITE EXPLICITE vers le legacy — jamais automatique
// (le scheduler ne l'appelle pas) ; uniquement via la route POST. Verrou de sync. dryRun => 0 écriture + aperçu.
function pushAssignments (opts = {}) {
const run = _syncLock.then(() => pushAssignmentsImpl(opts), () => pushAssignmentsImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '' } = {}) {
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 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
// Mapping PRINCIPAL par EMAIL : staff.email (legacy) = Dispatch Technician.email (= identité Authentik via ldap_id). Fiable, sans parsing.
const emails = [...new Set((techs || []).map(t => String(t.email || '').toLowerCase()).filter(Boolean))]
const [emRows] = emails.length ? await p.query('SELECT id, first_name, last_name, status, lower(email) AS email FROM staff WHERE status = 1 AND lower(email) IN (?)', [emails]) : [[]]
const staffByEmail = new Map((emRows || []).map(s => [s.email, s]))
// Repli : staff déduit de TECH-<id>, VALIDÉ par concordance de nom (pour les techs sans email).
const sufIds = [...new Set(jobs.map(j => (String(j.assigned_tech).match(/(\d{2,})$/) || [])[1]).filter(Boolean))]
const [sufRows] = sufIds.length ? await p.query('SELECT id, first_name, last_name, status FROM staff WHERE id IN (?)', [sufIds]) : [[]]
const staffById = new Map((sufRows || []).map(s => [String(s.id), s]))
const ids = [...new Set(jobs.map(j => parseInt(j.legacy_ticket_id, 10)).filter(Boolean))]
const [tk] = ids.length ? await p.query('SELECT id, status, assign_to FROM ticket WHERE id IN (?)', [ids]) : [[]]
const tkById = new Map((tk || []).map(r => [String(r.id), r]))
// Résout le staff legacy : email d'abord (autoritaire), sinon TECH-<id> validé par nom.
const resolveStaff = (techRow, assignedTech) => {
const email = String((techRow && techRow.email) || '').toLowerCase()
if (email && staffByEmail.has(email)) return { staff: staffByEmail.get(email), via: 'email' }
const sid = (String(assignedTech).match(/(\d{2,})$/) || [])[1]
const cand = sid ? staffById.get(sid) : null
if (cand) {
const a = norm((techRow && techRow.full_name) || ''); const b = norm((cand.first_name || '') + ' ' + (cand.last_name || ''))
if (a && b && (a === b || a.includes(b) || b.includes(a) || (a.split(' ')[0] === b.split(' ')[0] && a.split(' ').slice(-1)[0] === b.split(' ').slice(-1)[0]))) return { staff: cand, via: 'nom' }
}
return { staff: null, via: null }
}
let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0; const errSamples = []
const samples = []
for (const j of jobs) {
const techRow = techByName[j.assigned_tech]
const { staff: st, via } = resolveStaff(techRow, j.assigned_tech)
const tkrow = tkById.get(String(j.legacy_ticket_id))
if (!st || Number(st.status) !== 1) { mismatch++; if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, tech: j.assigned_tech, ops_nom: (techRow && techRow.full_name) || '(?)', action: '⚠ staff legacy introuvable (ni email ni nom) — ignoré' }); continue }
const staffId = String(st.id)
if (!tkrow || tkrow.status === 'closed') { skipped++; continue } // ticket fermé/introuvable → on ne réassigne pas
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 d = (w && w.data) || {}
if (d.ok) pushed++
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 }
}
// Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed » // Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed »
// (sort du pool / des listes ouvertes). NE touche PAS « In Progress » (tech en action). SÉQUENTIEL. // (sort du pool / des listes ouvertes). NE touche PAS « In Progress » (tech en action). SÉQUENTIEL.
async function closeResolved () { async function closeResolved () {
@ -414,6 +700,22 @@ async function handle (req, res, method, path) {
try { try {
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true })) if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false })) if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'GET') return json(res, 200, await reimportAddresses({ dryRun: true })) // aperçu (0 écriture)
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'POST') return json(res, 200, await reimportAddresses({ dryRun: false })) // applique
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'GET') return json(res, 200, await fillMissingCoords({ dryRun: true })) // aperçu géocodage des « hors carte »
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'POST') return json(res, 200, await fillMissingCoords({ dryRun: false })) // applique
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/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'] || '')
if (r && r.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(id)]], 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/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/reconcile' && method === 'GET') return json(res, 200, await reconcile()) 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/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)) } 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)) }
@ -428,4 +730,4 @@ async function handle (req, res, method, path) {
} }
} }
module.exports = { handle, sync, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType } // parseurs purs exposés pour les tests module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests

View File

@ -525,7 +525,7 @@ async function occupancyByTechDay (start, days) {
const dates = rangeDates(start, days) const dates = rangeDates(start, days)
const jobs = await erp.list('Dispatch Job', { const jobs = await erp.list('Dispatch Job', {
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]], 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'], 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'], limit: 5000,
}) })
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
const m = {} const m = {}
@ -538,7 +538,7 @@ async function occupancyByTechDay (start, days) {
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills) 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 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 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 || '', 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) }) 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) })
} }
// ordre = route_order manuel s'il existe, sinon priorité puis heure // 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))) 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)))
@ -750,7 +750,7 @@ async function handle (req, res, method, path, url) {
} }
// Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold). // Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold).
if (path === '/roster/unassigned-jobs' && method === 'GET') { 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'], orderBy: 'modified desc', limit: 400 }) 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 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 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
await attachLocations(jobs) await attachLocations(jobs)