gigafibre-fsm/apps/ops/src/composables/useBottomPanel.js
louispaulb 5e57b72a8f 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>
2026-06-06 12:37:45 -04:00

130 lines
7.6 KiB
JavaScript

// ── Bottom panel composable: unassigned jobs table, multi-select, criteria ────
import { ref, computed, watch } from 'vue'
import { localDateStr, fmtDate } from './useHelpers'
export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart }) {
const bottomPanelOpen = ref(localStorage.getItem('sbv2-bottomPanel') !== 'false')
const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220)
watch(bottomPanelOpen, v => localStorage.setItem('sbv2-bottomPanel', v ? 'true' : 'false'))
// ── 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) => {
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 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 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
})
// ── Resize ───────────────────────────────────────────────────────────────────
function startBottomResize (e) {
e.preventDefault()
const startY = e.clientY, startH = bottomPanelH.value
function onMove (ev) { bottomPanelH.value = Math.max(100, Math.min(window.innerHeight * 0.6, startH - (ev.clientY - startY))) }
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-bottomH', String(bottomPanelH.value)) }
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
}
// ── Multi-select ─────────────────────────────────────────────────────────────
const bottomSelected = ref(new Set())
function toggleBottomSelect (jobId, event) {
const s = new Set(bottomSelected.value)
// Checkbox click: always toggle (no modifier needed)
// Shift+click: range select
if (event?.shiftKey && s.size) {
const flat = unassignedGrouped.value.flatMap(g => g.jobs)
const ids = flat.map(j => j.id)
const lastId = [...s].pop()
const fromIdx = ids.indexOf(lastId), toIdx = ids.indexOf(jobId)
if (fromIdx >= 0 && toIdx >= 0) {
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]
for (let i = lo; i <= hi; i++) s.add(ids[i])
}
} else {
// Simple toggle (no Ctrl needed)
if (s.has(jobId)) s.delete(jobId); else s.add(jobId)
}
bottomSelected.value = s
}
function selectAllBottom () { const s = new Set(); unscheduledJobs.value.forEach(j => s.add(j.id)); bottomSelected.value = s }
function clearBottomSelect () { bottomSelected.value = new Set() }
function batchAssignBottom (techId) {
const dayStr = localDateStr(periodStart.value)
bottomSelected.value.forEach(jobId => {
const job = store.jobs.find(j => j.id === jobId)
if (job) {
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
smartAssign(job, techId, dayStr)
}
})
bottomSelected.value = new Set()
invalidateRoutes()
}
// ── Dispatch criteria ────────────────────────────────────────────────────────
const defaultCriteria = [
{ id: 'urgency', label: 'Urgence (priorité haute en premier)', enabled: true },
{ id: 'balance', label: 'Équilibrage de charge (tech le moins chargé)', enabled: true },
{ id: 'proximity', label: 'Proximité géographique', enabled: true },
{ id: 'skills', label: 'Correspondance des tags/skills', enabled: false },
]
const dispatchCriteria = ref(JSON.parse(localStorage.getItem('sbv2-dispatchCriteria') || 'null') || defaultCriteria.map(c => ({ ...c })))
const dispatchCriteriaModal = ref(false)
function saveDispatchCriteria () { localStorage.setItem('sbv2-dispatchCriteria', JSON.stringify(dispatchCriteria.value)); dispatchCriteriaModal.value = false }
function moveCriterion (idx, dir) {
const arr = dispatchCriteria.value, newIdx = idx + dir
if (newIdx < 0 || newIdx >= arr.length) return
const tmp = arr[idx]; arr[idx] = arr[newIdx]; arr[newIdx] = tmp
}
// ── Column widths ────────────────────────────────────────────────────────────
const btColWidths = ref(JSON.parse(localStorage.getItem('sbv2-btColW') || '{}'))
function btColW (col, def) { return (btColWidths.value[col] || def) + 'px' }
function startColResize (e, col) {
e.preventDefault(); e.stopPropagation()
const startX = e.clientX, startW = btColWidths.value[col] || parseInt(getComputedStyle(e.target.parentElement).width)
function onMove (ev) { btColWidths.value = { ...btColWidths.value, [col]: Math.max(40, startW + (ev.clientX - startX)) } }
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-btColW', JSON.stringify(btColWidths.value)) }
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
}
return {
bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
btColWidths, btColW, startColResize,
}
}