feat(dispatch): couleur ticket = couleur skill + fil complet du ticket + tri pool (date/ville/priorité)

- Couleurs liées aux skills (éditable/cohérent) : hub deptToSkill() déduit une compétence du type legacy
  → /roster/unassigned-jobs renvoie required_skill ; PlanificationPage colore la carte par getTagColor(required_skill)
  (même couleur que le chip skill) ; bordure 5px
- Fil complet du ticket : hub /dispatch/legacy-sync/ticket-thread (ticket_msg + auteur staff, HTML nettoyé) ;
  api legacyTicketThread ; RightPanel bouton « 💬 Voir le fil / commentaires » (chargé au clic, messages+auteurs+dates)
- Order-by du pool dispatch : useBottomPanel.bottomSort (date|city|priority) + dropdown ⇅ dans BottomPanel
  (ville = 2e segment adresse / token sujet avant |)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-06 12:37:45 -04:00
parent 6f709dd8e1
commit 5e57b72a8f
8 changed files with 110 additions and 30 deletions

View File

@ -93,5 +93,7 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu
export const unassignedJobs = () => jget('/roster/unassigned-jobs') export const unassignedJobs = () => jget('/roster/unassigned-jobs')
// 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
export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? } // Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body) export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)

View File

@ -7,32 +7,42 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm
const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220) const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220)
watch(bottomPanelOpen, v => localStorage.setItem('sbv2-bottomPanel', v ? 'true' : 'false')) watch(bottomPanelOpen, v => localStorage.setItem('sbv2-bottomPanel', v ? 'true' : 'false'))
// ── Grouped by date ────────────────────────────────────────────────────────── // ── Tri / regroupement (date · ville · priorité) ─────────────────────────────
const bottomSort = ref(localStorage.getItem('sbv2-bottomSort') || 'date')
watch(bottomSort, v => localStorage.setItem('sbv2-bottomSort', v))
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
// Ville : 2e segment de l'adresse libre, sinon 1er token du sujet avant « | » (tickets legacy), sinon vide.
function cityOf (job) {
const a = String(job.address || '')
const parts = a.split(',').map(s => s.trim()).filter(Boolean)
if (parts.length >= 2) return parts[1]
const subj = String(job.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim()
return parts[0] || ''
}
const unassignedGrouped = computed(() => { const unassignedGrouped = computed(() => {
const today = todayStr const today = todayStr
const sort = bottomSort.value
const jobs = unscheduledJobs.value.slice() const jobs = unscheduledJobs.value.slice()
const byDate = (a, b) => { // ordre secondaire : date (aujourd'hui d'abord)
const da = a.scheduledDate || '9999-99-99'; const db = b.scheduledDate || '9999-99-99'
const at = da === today ? 0 : 1; const bt = db === today ? 0 : 1
return at !== bt ? at - bt : da.localeCompare(db)
}
jobs.sort((a, b) => { jobs.sort((a, b) => {
const da = a.scheduledDate || '9999-99-99' if (sort === 'priority') return (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || byDate(a, b)
const db = b.scheduledDate || '9999-99-99' if (sort === 'city') return cityOf(a).localeCompare(cityOf(b)) || byDate(a, b)
const aToday = da === today ? 0 : 1 return byDate(a, b) || ((PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3))
const bToday = db === today ? 0 : 1
if (aToday !== bToday) return aToday - bToday
if (da !== db) return da.localeCompare(db)
const prio = { high: 0, medium: 1, low: 2 }
return (prio[a.priority] ?? 2) - (prio[b.priority] ?? 2)
}) })
const groups = [] const keyOf = job => sort === 'priority' ? (job.priority || 'low') : sort === 'city' ? (cityOf(job) || 'Sans ville') : (job.scheduledDate || null)
let currentDate = null const labelOf = key => {
if (sort === 'priority') return ({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[key] || key
if (sort === 'city') return key
return key === today ? "Aujourd'hui" : (key ? fmtDate(new Date(key + 'T00:00:00')) : 'Sans date')
}
const groups = []; let cur = Symbol('init')
jobs.forEach(job => { jobs.forEach(job => {
const d = job.scheduledDate || null const k = keyOf(job)
if (d !== currentDate) { if (k !== cur) { cur = k; groups.push({ key: String(k), date: sort === 'date' ? k : null, label: labelOf(k), jobs: [] }) }
currentDate = d
let label = d === today ? "Aujourd'hui" : d ? d : 'Sans date'
if (d && d !== today) {
label = fmtDate(new Date(d + 'T00:00:00'))
}
groups.push({ date: d, label, jobs: [] })
}
groups.at(-1).jobs.push(job) groups.at(-1).jobs.push(job)
}) })
return groups return groups
@ -111,7 +121,7 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm
} }
return { return {
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize, bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom, bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion, dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
btColWidths, btColW, startColResize, btColWidths, btColW, startColResize,

View File

@ -9,10 +9,11 @@ const props = defineProps({
unscheduledCount: Number, unscheduledCount: Number,
selected: Object, // Set selected: Object, // Set
dropActive: Boolean, dropActive: Boolean,
sort: { type: String, default: 'date' }, // tri du pool : date | city | priority
}) })
const emit = defineEmits([ const emit = defineEmits([
'update:open', 'update:height', 'resize-start', 'update:open', 'update:height', 'resize-start', 'update:sort',
'toggle-select', 'select-all', 'clear-select', 'batch-assign', 'toggle-select', 'select-all', 'clear-select', 'batch-assign',
'auto-distribute', 'open-criteria', 'auto-distribute', 'open-criteria',
'row-click', 'row-dblclick', 'row-dragstart', 'row-click', 'row-dblclick', 'row-dragstart',
@ -106,6 +107,11 @@ function btLassoEnd () {
</span> </span>
<button v-if="unscheduledCount" class="sbf-auto-btn" @click="emit('auto-distribute')" title="Répartir automatiquement"> Répartir auto</button> <button v-if="unscheduledCount" class="sbf-auto-btn" @click="emit('auto-distribute')" title="Répartir automatiquement"> Répartir auto</button>
<button class="sbf-auto-btn" style="border-color:rgba(255,255,255,0.12)" @click="emit('open-criteria')" title="Critères de dispatch"> Critères</button> <button class="sbf-auto-btn" style="border-color:rgba(255,255,255,0.12)" @click="emit('open-criteria')" title="Critères de dispatch"> Critères</button>
<label style="display:inline-flex;align-items:center;gap:4px;font-size:0.72rem;opacity:.85" title="Trier le pool">
<select :value="sort" @change="emit('update:sort', $event.target.value)" style="background:rgba(255,255,255,0.06);color:inherit;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:1px 4px;font-size:0.72rem">
<option value="date">Date</option><option value="city">Ville</option><option value="priority">Priorité</option>
</select>
</label>
<!-- Batch assign bar --> <!-- Batch assign bar -->
<template v-if="selected.size"> <template v-if="selected.size">
<span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span> <span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
@ -141,7 +147,7 @@ function btLassoEnd () {
</thead> </thead>
</table> </table>
<div class="sb-bottom-scroll" ref="btScrollRef" @mousedown="btLassoStart" style="position:relative"> <div class="sb-bottom-scroll" ref="btScrollRef" @mousedown="btLassoStart" style="position:relative">
<template v-for="group in groups" :key="group.date||'nodate'"> <template v-for="group in groups" :key="group.key || group.date || 'nodate'">
<div class="sb-bottom-date-sep"> <div class="sb-bottom-date-sep">
<span class="sb-bottom-date-label">{{ group.label }}</span> <span class="sb-bottom-date-label">{{ group.label }}</span>
<span class="sb-bottom-date-count">{{ group.jobs.length }}</span> <span class="sb-bottom-date-count">{{ group.jobs.length }}</span>

View File

@ -1,12 +1,25 @@
<script setup> <script setup>
import { inject } from 'vue' import { inject, ref, watch } from 'vue'
import { fmtDur, prioLabel, prioClass, ICON, legacyReplyUrl } from 'src/composables/useHelpers' import { fmtDur, prioLabel, prioClass, ICON, legacyReplyUrl } from 'src/composables/useHelpers'
import { legacyTicketThread } from 'src/api/roster'
import TagEditor from 'src/components/shared/TagEditor.vue' import TagEditor from 'src/components/shared/TagEditor.vue'
const props = defineProps({ const props = defineProps({
panel: Object, // { mode, data: { job, tech } } or null panel: Object, // { mode, data: { job, tech } } or null
}) })
// Fil legacy (description + commentaires/réponses) chargé À LA DEMANDE au clic.
const thread = ref(null); const threadLoading = ref(false); const threadOpen = ref(false)
async function toggleThread () {
const id = props.panel?.data?.job?.legacyTicketId; if (!id) return
threadOpen.value = !threadOpen.value
if (!threadOpen.value || thread.value) return
threadLoading.value = true
try { thread.value = await legacyTicketThread(id) } catch (e) { thread.value = { error: String(e.message || e) } } finally { threadLoading.value = false }
}
function fmtThreadDate (iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) }
watch(() => props.panel?.data?.job?.legacyTicketId, () => { thread.value = null; threadOpen.value = false }) // reset quand on change de job
const emit = defineEmits([ const emit = defineEmits([
'close', 'edit', 'move', 'geofix', 'unassign', 'close', 'edit', 'move', 'geofix', 'unassign',
'set-end-date', 'remove-assistant', 'assign-pending', 'set-end-date', 'remove-assistant', 'assign-pending',
@ -94,6 +107,22 @@ const onDeleteTag = inject('onDeleteTag')
<span class="sb-rp-lbl">Détails du ticket</span> <span class="sb-rp-lbl">Détails du ticket</span>
<div style="white-space:pre-wrap;max-height:200px;overflow:auto;font-size:0.78rem;line-height:1.4;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:6px 8px;margin-top:2px">{{ panel.data.job.legacyDetail }}</div> <div style="white-space:pre-wrap;max-height:200px;overflow:auto;font-size:0.78rem;line-height:1.4;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:6px 8px;margin-top:2px">{{ panel.data.job.legacyDetail }}</div>
</div> </div>
<!-- Fil complet du ticket legacy : commentaires / réponses des collaborateurs (chargé au clic) -->
<div v-if="panel.data?.job?.legacyTicketId" class="sb-rp-field">
<button class="sb-rp-btn" style="width:100%;text-align:left" @click="toggleThread">
💬 {{ threadOpen ? 'Masquer' : 'Voir' }} le fil du ticket / commentaires
<span v-if="thread && thread.count != null" style="opacity:.7">({{ thread.count }})</span>
</button>
<div v-if="threadOpen" style="margin-top:6px;max-height:300px;overflow:auto">
<div v-if="threadLoading" style="font-size:.78rem;opacity:.7;padding:4px">Chargement</div>
<div v-else-if="thread && thread.error" style="font-size:.78rem;color:#ef4444;padding:4px">Erreur : {{ thread.error }}</div>
<div v-else-if="thread && !thread.messages?.length" style="font-size:.78rem;opacity:.7;padding:4px">Aucun message.</div>
<div v-for="(m, i) in (thread?.messages || [])" :key="i" style="border-left:2px solid rgba(255,255,255,0.12);padding:3px 8px;margin-bottom:6px">
<div style="font-size:.68rem;opacity:.7"><b>{{ m.author }}</b> · {{ fmtThreadDate(m.at) }}</div>
<div style="white-space:pre-wrap;font-size:.76rem;line-height:1.35">{{ m.text }}</div>
</div>
</div>
</div>
<div v-if="panel.data?.job?.note" class="sb-rp-field"><span class="sb-rp-lbl">Note</span>{{ panel.data.job.note }}</div> <div v-if="panel.data?.job?.note" class="sb-rp-field"><span class="sb-rp-lbl">Note</span>{{ panel.data.job.note }}</div>
<div class="sb-rp-field"> <div class="sb-rp-field">
<span class="sb-rp-lbl">Tags</span> <span class="sb-rp-lbl">Tags</span>

View File

@ -310,7 +310,7 @@ const {
} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal }) } = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal })
const { const {
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize, bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom, bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion, dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
btColWidths, btColW, startColResize, btColWidths, btColW, startColResize,
@ -1634,6 +1634,7 @@ onUnmounted(() => {
<BottomPanel :open="bottomPanelOpen" :height="bottomPanelH" <BottomPanel :open="bottomPanelOpen" :height="bottomPanelH"
:groups="unassignedGrouped" :unscheduled-count="unscheduledJobs.length" :groups="unassignedGrouped" :unscheduled-count="unscheduledJobs.length"
:sort="bottomSort" @update:sort="v => bottomSort = v"
:selected="bottomSelected" :drop-active="unassignDropActive" :selected="bottomSelected" :drop-active="unassignDropActive"
@update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize" @update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize"
@toggle-select="toggleBottomSelect" @select-all="selectAllBottom" @clear-select="clearBottomSelect" @toggle-select="toggleBottomSelect" @select-all="selectAllBottom" @clear-select="clearBottomSelect"

View File

@ -544,7 +544,7 @@
<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>
<div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }"> <div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }">
<div v-if="grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) tout sélectionner (terrain)</div> <div v-if="grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) tout sélectionner (terrain)</div>
<div v-for="(j, idx) in grp.jobs" :key="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name) }" :style="{ borderLeft: '4px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd"> <div v-for="(j, idx) in grp.jobs" :key="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name) }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
<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>
@ -966,9 +966,9 @@ function hashColor (label) { let h = 0; for (const c of String(label)) h = (h *
const customTags = ref([]) // [{label,color}] créés à la volée (localStorage) const customTags = ref([]) // [{label,color}] créés à la volée (localStorage)
function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) } function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) }
function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) } function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) }
// Couleur d'une carte job du panneau « à assigner » : type legacy type ERPNext compétence (cohérent avec le board Dispatch) // Couleur d'une carte job = COULEUR DE SA COMPÉTENCE (éditable via le gestionnaire de tags cohérent + simple).
const JOBTYPE_COLOR = { Installation: '#46992f', 'Réparation': '#f1c84b', Retrait: '#c0392b', 'Dépannage': '#f59e0b', Autre: '#90a4ae' } // required_skill est renseigné côté hub (skill explicite, sinon déduit du type legacy). Repli : couleur du type.
function panelJobColor (j) { return legacyDeptColor(j.legacy_dept) || JOBTYPE_COLOR[j.job_type] || (j.required_skill ? getTagColor(j.required_skill) : '#90a4ae') } function panelJobColor (j) { return j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae') }
const tagCatalog = computed(() => { const tagCatalog = computed(() => {
const m = new Map() const m = new Map()
for (const ct of customTags.value) m.set(ct.label, { name: ct.label, label: ct.label, color: ct.color || hashColor(ct.label), category: 'Custom' }) for (const ct of customTags.value) m.set(ct.label, { name: ct.label, label: ct.label, color: ct.color || hashColor(ct.label), category: 'Custom' })

View File

@ -217,6 +217,23 @@ 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 }
} }
// Fil COMPLET d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only.
async function ticketThread (legacyId) {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const id = String(legacyId || '').replace(/[^0-9]/g, ''); if (!id) return { ok: false, error: 'id invalide' }
const [trows] = await p.query('SELECT subject, status FROM ticket WHERE id = ? LIMIT 1', [id])
const [rows] = await p.query(
`SELECT mm.id, mm.date_orig, mm.staff_id, s.first_name, s.last_name, s.username, mm.msg
FROM ticket_msg mm LEFT JOIN staff s ON s.id = mm.staff_id
WHERE mm.ticket_id = ? ORDER BY mm.id ASC LIMIT 200`, [id])
const messages = (rows || []).map(r => ({
at: r.date_orig ? new Date(Number(r.date_orig) * 1000).toISOString() : null,
author: [r.first_name, r.last_name].filter(Boolean).join(' ') || r.username || (r.staff_id ? ('Staff ' + r.staff_id) : 'Système / client'),
text: stripHtml(r.msg, 6000),
})).filter(m => m.text)
return { ok: true, ticket: id, subject: (trows && trows[0] && trows[0].subject) || '', status: (trows && trows[0] && trows[0].status) || '', count: messages.length, messages }
}
// ── Récurrence (setInterval) ── // ── Récurrence (setInterval) ──
let _timer = null let _timer = null
let _lastRun = null // heartbeat : dernier passage réussi (pour /status + Uptime-Kuma) let _lastRun = null // heartbeat : dernier passage réussi (pour /status + Uptime-Kuma)
@ -239,6 +256,7 @@ async function handle (req, res, method, path) {
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/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/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/status' && method === 'GET') { // heartbeat pour Uptime-Kuma (keyword "stale":false) if (path === '/dispatch/legacy-sync/status' && method === 'GET') { // heartbeat pour Uptime-Kuma (keyword "stale":false)
const ageMin = _lastRun ? Math.round((Date.now() - Date.parse(_lastRun.at)) / 60000) : null const ageMin = _lastRun ? Math.round((Date.now() - Date.parse(_lastRun.at)) / 60000) : null
const max = (Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15) * 3 // toléré = 3 ticks const max = (Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15) * 3 // toléré = 3 ticks

View File

@ -277,6 +277,20 @@ function skillForJob (job) {
const map = getBookingPolicy().skill_by_type || {} const map = getBookingPolicy().skill_by_type || {}
return String(map[job.service_type] || '').trim() return String(map[job.service_type] || '').trim()
} }
// Repli : déduit une COMPÉTENCE (parmi les skills réels des techs) depuis le département/type legacy.
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
function deptToSkill (txt) {
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (!d) return ''
if (/teleph/.test(d)) return 'telephone'
if (/tele|televis/.test(d)) return 'tv'
if (/fusion|episs/.test(d)) return 'épissure'
if (/monteur|aerien/.test(d)) return 'monteur'
if (/netadmin|net admin/.test(d)) return 'netadmin'
if (/repar|desinstall/.test(d)) return 'réparation'
if (/install|fibre/.test(d)) return 'installation'
return ''
}
// Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »). // Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »).
// Batch : 1 seule requête sur Service Location pour tous les codes distincts. // Batch : 1 seule requête sur Service Location pour tous les codes distincts.
async function attachLocations (jobs) { async function attachLocations (jobs) {
@ -736,7 +750,7 @@ async function handle (req, res, method, path, url) {
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'], 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) 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)
return json(res, 200, { jobs }) return json(res, 200, { jobs })
} }