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:
parent
6f709dd8e1
commit
5e57b72a8f
|
|
@ -93,5 +93,7 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu
|
|||
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
|
||||
// Assigner un job à un tech (date = case déposée)
|
||||
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? }
|
||||
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
|
||||
|
|
|
|||
|
|
@ -7,32 +7,42 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm
|
|||
const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220)
|
||||
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 today = todayStr
|
||||
const sort = bottomSort.value
|
||||
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) => {
|
||||
const da = a.scheduledDate || '9999-99-99'
|
||||
const db = b.scheduledDate || '9999-99-99'
|
||||
const aToday = da === today ? 0 : 1
|
||||
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)
|
||||
if (sort === 'priority') return (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || byDate(a, b)
|
||||
if (sort === 'city') return cityOf(a).localeCompare(cityOf(b)) || byDate(a, b)
|
||||
return byDate(a, b) || ((PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3))
|
||||
})
|
||||
const groups = []
|
||||
let currentDate = null
|
||||
const keyOf = job => sort === 'priority' ? (job.priority || 'low') : sort === 'city' ? (cityOf(job) || 'Sans ville') : (job.scheduledDate || 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 => {
|
||||
const d = job.scheduledDate || null
|
||||
if (d !== currentDate) {
|
||||
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: [] })
|
||||
}
|
||||
const k = keyOf(job)
|
||||
if (k !== cur) { cur = k; groups.push({ key: String(k), date: sort === 'date' ? k : null, label: labelOf(k), jobs: [] }) }
|
||||
groups.at(-1).jobs.push(job)
|
||||
})
|
||||
return groups
|
||||
|
|
@ -111,7 +121,7 @@ export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, sm
|
|||
}
|
||||
|
||||
return {
|
||||
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
|
||||
bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
|
||||
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
|
||||
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
|
||||
btColWidths, btColW, startColResize,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ const props = defineProps({
|
|||
unscheduledCount: Number,
|
||||
selected: Object, // Set
|
||||
dropActive: Boolean,
|
||||
sort: { type: String, default: 'date' }, // tri du pool : date | city | priority
|
||||
})
|
||||
|
||||
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',
|
||||
'auto-distribute', 'open-criteria',
|
||||
'row-click', 'row-dblclick', 'row-dragstart',
|
||||
|
|
@ -106,6 +107,11 @@ function btLassoEnd () {
|
|||
</span>
|
||||
<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>
|
||||
<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 -->
|
||||
<template v-if="selected.size">
|
||||
<span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
|
||||
|
|
@ -141,7 +147,7 @@ function btLassoEnd () {
|
|||
</thead>
|
||||
</table>
|
||||
<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">
|
||||
<span class="sb-bottom-date-label">{{ group.label }}</span>
|
||||
<span class="sb-bottom-date-count">{{ group.jobs.length }}</span>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { inject, ref, watch } from 'vue'
|
||||
import { fmtDur, prioLabel, prioClass, ICON, legacyReplyUrl } from 'src/composables/useHelpers'
|
||||
import { legacyTicketThread } from 'src/api/roster'
|
||||
import TagEditor from 'src/components/shared/TagEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
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([
|
||||
'close', 'edit', 'move', 'geofix', 'unassign',
|
||||
'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>
|
||||
<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>
|
||||
<!-- 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 class="sb-rp-field">
|
||||
<span class="sb-rp-lbl">Tags</span>
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ const {
|
|||
} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal })
|
||||
|
||||
const {
|
||||
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
|
||||
bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
|
||||
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
|
||||
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
|
||||
btColWidths, btColW, startColResize,
|
||||
|
|
@ -1634,6 +1634,7 @@ onUnmounted(() => {
|
|||
|
||||
<BottomPanel :open="bottomPanelOpen" :height="bottomPanelH"
|
||||
:groups="unassignedGrouped" :unscheduled-count="unscheduledJobs.length"
|
||||
:sort="bottomSort" @update:sort="v => bottomSort = v"
|
||||
:selected="bottomSelected" :drop-active="unassignDropActive"
|
||||
@update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize"
|
||||
@toggle-select="toggleBottomSelect" @select-all="selectAllBottom" @clear-select="clearBottomSelect"
|
||||
|
|
|
|||
|
|
@ -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-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-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">
|
||||
<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>
|
||||
|
|
@ -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)
|
||||
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) }
|
||||
// Couleur d'une carte job du panneau « à assigner » : type legacy → type ERPNext → compétence (cohérent avec le board Dispatch)
|
||||
const JOBTYPE_COLOR = { Installation: '#46992f', 'Réparation': '#f1c84b', Retrait: '#c0392b', 'Dépannage': '#f59e0b', Autre: '#90a4ae' }
|
||||
function panelJobColor (j) { return legacyDeptColor(j.legacy_dept) || JOBTYPE_COLOR[j.job_type] || (j.required_skill ? getTagColor(j.required_skill) : '#90a4ae') }
|
||||
// Couleur d'une carte job = COULEUR DE SA COMPÉTENCE (éditable via le gestionnaire de tags → cohérent + simple).
|
||||
// required_skill est renseigné côté hub (skill explicite, sinon déduit du type legacy). Repli : couleur du type.
|
||||
function panelJobColor (j) { return j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae') }
|
||||
const tagCatalog = computed(() => {
|
||||
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' })
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
// 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) ──
|
||||
let _timer = null
|
||||
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/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/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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -277,6 +277,20 @@ function skillForJob (job) {
|
|||
const map = getBookingPolicy().skill_by_type || {}
|
||||
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-… »).
|
||||
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
|
||||
async function attachLocations (jobs) {
|
||||
|
|
@ -736,7 +750,7 @@ async function handle (req, res, method, path, url) {
|
|||
if (path === '/roster/unassigned-jobs' && method === 'GET') {
|
||||
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 })
|
||||
const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés
|
||||
for (const j of jobs) j.required_skill = skillForJob(j)
|
||||
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)
|
||||
return json(res, 200, { jobs })
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user