diff --git a/apps/ops/src/components/dispatch/NlpInput.vue b/apps/ops/src/components/dispatch/NlpInput.vue deleted file mode 100644 index 40bff43..0000000 --- a/apps/ops/src/components/dispatch/NlpInput.vue +++ /dev/null @@ -1,166 +0,0 @@ - - - - - diff --git a/apps/ops/src/composables/useAutoDispatch.js b/apps/ops/src/composables/useAutoDispatch.js deleted file mode 100644 index 1d34868..0000000 --- a/apps/ops/src/composables/useAutoDispatch.js +++ /dev/null @@ -1,139 +0,0 @@ -// ── Auto-dispatch composable: autoDistribute + optimizeRoute ───────────────── -import { localDateStr } from './useHelpers' -import { updateJob } from 'src/api/dispatch' -import * as roster from 'src/api/roster' // TSP via NOTRE OSRM (plus de Mapbox optimized-trips) - -export function useAutoDispatch (deps) { - const { store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs, bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes } = deps - - async function autoDistribute () { - const techs = filteredResources.value - if (!techs.length) return - const today = localDateStr(new Date()) - let pool - if (bottomSelected.value.size) { - pool = [...bottomSelected.value].map(id => store.jobs.find(j => j.id === id)).filter(Boolean) - } else { - pool = unscheduledJobs.value.filter(j => !j.scheduledDate || j.scheduledDate === today) - } - if (!pool.length) return - // Jobs with coords get proximity-based assignment, jobs without get load-balanced only - const withCoords = pool.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) - const noCoords = pool.filter(j => !j.coords || (j.coords[0] === 0 && j.coords[1] === 0)) - const unassigned = [...withCoords, ...noCoords] - if (!unassigned.length) return - - const prevQueues = {} - techs.forEach(t => { prevQueues[t.id] = [...t.queue] }) - const prevAssignments = unassigned.map(j => ({ jobId: j.id, techId: j.assignedTech, scheduledDate: j.scheduledDate })) - - function techLoadForDay (tech, dayStr) { - return tech.queue.filter(j => getJobDate(j.id) === dayStr).reduce((s, j) => s + (parseFloat(j.duration) || 1), 0) - } - function dist (a, b) { - if (!a || !b) return 999 - const dx = (a[0] - b[0]) * 80, dy = (a[1] - b[1]) * 111 - return Math.sqrt(dx * dx + dy * dy) - } - function techLastPosForDay (tech, dayStr) { - const dj = tech.queue.filter(j => getJobDate(j.id) === dayStr) - if (dj.length) { const last = dj[dj.length - 1]; if (last.coords && last.coords[0] !== 0) return last.coords } - return tech.coords - } - - const criteria = dispatchCriteria.value.filter(c => c.enabled) - const sorted = [...unassigned].sort((a, b) => { - for (const c of criteria) { - if (c.id === 'urgency') { - const p = { high: 0, medium: 1, low: 2 } - const diff = (p[a.priority] ?? 2) - (p[b.priority] ?? 2) - if (diff !== 0) return diff - } - } - return 0 - }) - - const useSkills = criteria.some(c => c.id === 'skills') - const weights = {} - criteria.forEach((c, i) => { weights[c.id] = criteria.length - i }) - - sorted.forEach(job => { - const assignDay = job.scheduledDate || today - let bestTech = null, bestScore = Infinity - techs.forEach(tech => { - let score = 0 - if (weights.balance) score += techLoadForDay(tech, assignDay) * (weights.balance || 1) - if (weights.proximity && job.coords && (job.coords[0] !== 0 || job.coords[1] !== 0)) score += dist(techLastPosForDay(tech, assignDay), job.coords) / 60 * (weights.proximity || 1) - if (weights.skills && useSkills) { - const jt = job.tags || [], tt = tech.tags || [] - score += (jt.length > 0 ? (jt.length - jt.filter(t => tt.includes(t)).length) * 2 : 0) * (weights.skills || 1) - } - if (score < bestScore) { bestScore = score; bestTech = tech } - }) - if (bestTech) store.smartAssign(job.id, bestTech.id, assignDay) - }) - - pushUndo({ type: 'autoDistribute', assignments: prevAssignments, prevQueues }) - bottomSelected.value = new Set() - invalidateRoutes() - } - - async function optimizeRoute (tech) { - const dayStr = localDateStr(periodStart.value) - const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr) - if (dayJobs.length < 2) return - const jobsWithCoords = dayJobs.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) - if (jobsWithCoords.length < 2) return - - const urgent = jobsWithCoords.filter(j => j.priority === 'high') - const normal = jobsWithCoords.filter(j => j.priority !== 'high') - - function nearestNeighbor (start, jobs) { - const result = [], remaining = [...jobs] - let cur = start - while (remaining.length) { - let bi = 0, bd = Infinity - remaining.forEach((j, i) => { - const dx = j.coords[0] - cur[0], dy = j.coords[1] - cur[1], d = dx * dx + dy * dy - if (d < bd) { bd = d; bi = i } - }) - result.push(remaining.splice(bi, 1)[0]) - cur = result.at(-1).coords - } - return result - } - - const home = (tech.coords?.[0] && tech.coords?.[1]) ? tech.coords : jobsWithCoords[0].coords - const orderedUrgent = nearestNeighbor(home, urgent) - const orderedNormal = nearestNeighbor(orderedUrgent.length ? orderedUrgent.at(-1).coords : home, normal) - const reordered = [...orderedUrgent, ...orderedNormal] - - try { - const hasHome = !!(tech.coords?.[0] && tech.coords?.[1]) - const coords = [] - if (hasHome) coords.push([tech.coords[0], tech.coords[1]]) - reordered.forEach(j => coords.push([j.coords[0], j.coords[1]])) - if (coords.length >= 2 && coords.length <= 12) { - const data = await roster.osrmTrip(coords) // NOTRE OSRM /trip (via hub) — même forme que Mapbox optimized-trips - if (data.code === 'Ok' && data.waypoints) { - const off = hasHome ? 1 : 0, uc = orderedUrgent.length - const mu = reordered.slice(0, uc).map((j, i) => ({ job: j, o: data.waypoints[i + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job) - const mn = reordered.slice(uc).map((j, i) => ({ job: j, o: data.waypoints[i + uc + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job) - reordered.length = 0 - reordered.push(...mu, ...mn) - } - } - } catch (_) {} - - pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] }) - const otherJobs = tech.queue.filter(j => getJobDate(j.id) !== dayStr) - tech.queue = [...reordered, ...otherJobs] - tech.queue.forEach((j, i) => { - j.routeOrder = i - updateJob(j.name || j.id, { route_order: i, start_time: '' }).catch(() => {}) - }) - invalidateRoutes() - } - - return { autoDistribute, optimizeRoute } -} diff --git a/apps/ops/src/composables/useBottomPanel.js b/apps/ops/src/composables/useBottomPanel.js deleted file mode 100644 index 7a91313..0000000 --- a/apps/ops/src/composables/useBottomPanel.js +++ /dev/null @@ -1,129 +0,0 @@ -// ── 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, - } -} diff --git a/apps/ops/src/composables/useDragDrop.js b/apps/ops/src/composables/useDragDrop.js deleted file mode 100644 index 7ba656b..0000000 --- a/apps/ops/src/composables/useDragDrop.js +++ /dev/null @@ -1,242 +0,0 @@ -// ── Drag & Drop composable: job drag, tech drag, block move, block resize, batch drag ── -import { ref } from 'vue' -import { snapH, hToTime, fmtDur, localDateStr, SNAP, serializeAssistants } from './useHelpers' -import { updateJob } from 'src/api/dispatch' - -export function useDragDrop (deps) { - const { - store, pxPerHr, dayW, periodStart, periodDays, H_START, - getJobDate, bottomSelected, multiSelect, - pushUndo, smartAssign, invalidateRoutes, - } = deps - - const dragJob = ref(null) - const dragSrc = ref(null) - const dragIsAssist = ref(false) - const dropGhost = ref(null) - const dragTech = ref(null) - const dragBatchIds = ref(null) - - function cleanupDropIndicators () { - document.querySelectorAll('.sb-block-drop-hover').forEach(el => el.classList.remove('sb-block-drop-hover')) - dropGhost.value = null - } - - function onJobDragStart (e, job, srcTechId, isAssist = false) { - dragJob.value = job; dragSrc.value = srcTechId || null; dragIsAssist.value = isAssist - if (!srcTechId && bottomSelected.value.size > 1 && bottomSelected.value.has(job.id)) { - dragBatchIds.value = new Set(bottomSelected.value) - e.dataTransfer.setData('text/plain', `batch:${dragBatchIds.value.size}`) - } else { - dragBatchIds.value = null - } - e.dataTransfer.effectAllowed = 'move' - e.target.addEventListener('dragend', () => { cleanupDropIndicators(); dragIsAssist.value = false; dragBatchIds.value = null }, { once: true }) - } - - function onTimelineDragOver (e, tech) { - e.preventDefault() - if (!dragJob.value && !dragTech.value) return - const x = e.clientX - e.currentTarget.getBoundingClientRect().left - dropGhost.value = { techId: tech.id, x, dateStr: xToDateStr(x) } - } - - function onTimelineDragLeave (e) { - if (!e.currentTarget.contains(e.relatedTarget)) dropGhost.value = null - } - - function onTechDragStart (e, tech) { - dragTech.value = tech - e.dataTransfer.effectAllowed = 'copyMove' - e.dataTransfer.setData('text/plain', tech.id) - e.target.addEventListener('dragend', () => { dragTech.value = null; cleanupDropIndicators() }, { once: true }) - return tech - } - - function onBlockDrop (e, job) { - if (dragTech.value) { - e.preventDefault(); e.stopPropagation() - cleanupDropIndicators() - pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' }) - store.addAssistant(job.id, dragTech.value.id) - dragTech.value = null - invalidateRoutes() - } - } - - function assignDroppedJob (tech, dateStr) { - if (!dragJob.value) return - if (dragIsAssist.value) { - dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return - } - if (dragBatchIds.value && dragBatchIds.value.size > 1) { - const prevStates = [] - dragBatchIds.value.forEach(jobId => { - const j = store.jobs.find(x => x.id === jobId) - if (j && !j.assignedTech) { - prevStates.push({ jobId: j.id, techId: j.assignedTech, routeOrder: j.routeOrder, scheduledDate: j.scheduledDate, assistants: [...(j.assistants || [])] }) - smartAssign(j, tech.id, dateStr) - } - }) - if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, targetTechId: tech.id }) - bottomSelected.value = new Set() - dragBatchIds.value = null - } else if (multiSelect && multiSelect.value?.length > 1 && multiSelect.value.some(s => s.job.id === dragJob.value.id)) { - // Dragging a multi-selected block from timeline — move all selected - const prevStates = [] - const prevQueues = {} - store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] }) - multiSelect.value.filter(s => !s.isAssist).forEach(s => { - prevStates.push({ jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder, scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])] }) - smartAssign(s.job, tech.id, dateStr) - }) - if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, prevQueues }) - multiSelect.value = [] - } else { - const job = dragJob.value - pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] }) - smartAssign(job, tech.id, dateStr) - } - dropGhost.value = null; dragJob.value = null; dragSrc.value = null - invalidateRoutes() - } - - function onTimelineDrop (e, tech) { - e.preventDefault() - cleanupDropIndicators() - - if (dragTech.value) { - const els = document.elementsFromPoint(e.clientX, e.clientY) - const blockEl = els.find(el => el.dataset?.jobId) - if (blockEl) { - const job = store.jobs.find(j => j.id === blockEl.dataset.jobId) - if (job) { - pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' }) - store.addAssistant(job.id, dragTech.value.id) - dragTech.value = null; invalidateRoutes(); return - } - } - dragTech.value = null; return - } - - if (!dragJob.value) return - - if (dragJob.value.assignedTech === tech.id) { - const rect = e.currentTarget.getBoundingClientRect() - const x = (e.clientX || e.pageX) - rect.left - const dropH = H_START.value + x / pxPerHr.value - const dayStr = localDateStr(periodStart.value) - pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] }) - const draggedJob = dragJob.value - tech.queue = tech.queue.filter(j => j.id !== draggedJob.id) - const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr) - const queueDayStart = tech.queue.findIndex(j => getJobDate(j.id) === dayStr) - let slot = dayJobs.length, cursor = 8 - for (let i = 0; i < dayJobs.length; i++) { - const dur = parseFloat(dayJobs[i].duration) || 1 - if (dropH < cursor + dur / 2) { slot = i; break } - cursor += dur + 0.5 - } - const insertAt = queueDayStart >= 0 ? queueDayStart + slot : tech.queue.length - tech.queue.splice(insertAt, 0, draggedJob) - tech.queue.forEach((q, i) => { q.routeOrder = i; updateJob(q.name || q.id, { route_order: i }).catch(() => {}) }) - dragJob.value = null; dragSrc.value = null; invalidateRoutes(); return - } - - if (dragIsAssist.value) { - dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return - } - - assignDroppedJob(tech, xToDateStr(e.clientX - e.currentTarget.getBoundingClientRect().left)) - } - - function onCalDrop (e, tech, dateStr) { assignDroppedJob(tech, dateStr) } - - function xToDateStr (x) { - const di = Math.max(0, Math.min(periodDays.value - 1, Math.floor(x / dayW.value))) - const d = new Date(periodStart.value); d.setDate(d.getDate() + di) - return localDateStr(d) - } - - function startBlockMove (e, job, block) { - if (e.button !== 0) return - const startX = e.clientX, startY = e.clientY - const startLeft = parseFloat(block.style.left) || 0 - let moving = false - function onMove (ev) { - const dx = ev.clientX - startX, dy = ev.clientY - startY - if (!moving && Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 5) { cleanup(); return } - if (!moving && Math.abs(dx) > 5) { moving = true; block.style.zIndex = '10' } - if (!moving) return - ev.preventDefault() - const newLeft = Math.max(0, startLeft + dx) - const newH = snapH(H_START.value + newLeft / pxPerHr.value) - block.style.left = ((newH - H_START.value) * pxPerHr.value) + 'px' - const meta = block.querySelector('.sb-block-meta') - if (meta) meta.textContent = `${hToTime(newH)} · ${fmtDur(job.duration)}` - } - function cleanup () { - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) - } - function onUp (ev) { - cleanup() - if (!moving) return - block.style.zIndex = '' - const dx = ev.clientX - startX - const newH = snapH(H_START.value + Math.max(0, startLeft + dx) / pxPerHr.value) - job.startHour = newH; job.startTime = hToTime(newH) - store.setJobSchedule(job.id, job.scheduledDate, hToTime(newH)) - invalidateRoutes() - } - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) - } - - function startResize (e, job, mode, assistTechId) { - e.preventDefault() - const startX = e.clientX - const startDur = mode === 'assist' - ? (job.assistants.find(a => a.techId === assistTechId)?.duration || job.duration) - : job.duration - const block = e.target.parentElement - const startW = block.offsetWidth - function onMove (ev) { - const dx = ev.clientX - startX - const newDur = Math.max(SNAP, snapH(Math.max(18, startW + dx) / pxPerHr.value)) - block.style.width = (newDur * pxPerHr.value) + 'px' - const meta = block.querySelector('.sb-block-meta') - if (meta) meta.textContent = mode === 'assist' ? `assistant · ${fmtDur(newDur)}` : fmtDur(newDur) - } - function onUp (ev) { - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) - const dx = ev.clientX - startX - const newDur = Math.max(SNAP, snapH(Math.max(18, startW + dx) / pxPerHr.value)) - if (mode === 'assist' && assistTechId) { - const assist = job.assistants.find(a => a.techId === assistTechId) - if (assist) { - assist.duration = newDur - updateJob(job.name || job.id, { - assistants: serializeAssistants(job.assistants), - }).catch(() => {}) - } - } else { - job.duration = newDur - updateJob(job.name || job.id, { duration_h: newDur }).catch(() => {}) - } - invalidateRoutes() - } - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) - } - - return { - dragJob, dragSrc, dragIsAssist, dropGhost, dragTech, dragBatchIds, - cleanupDropIndicators, - onJobDragStart, onTimelineDragOver, onTimelineDragLeave, - onTechDragStart, onBlockDrop, - assignDroppedJob, onTimelineDrop, onCalDrop, xToDateStr, - startBlockMove, startResize, - } -} diff --git a/apps/ops/src/composables/useGpsTracking.js b/apps/ops/src/composables/useGpsTracking.js deleted file mode 100644 index 499e7dd..0000000 --- a/apps/ops/src/composables/useGpsTracking.js +++ /dev/null @@ -1,66 +0,0 @@ -import { ref } from 'vue' -import { fetchDevices, fetchPositions } from 'src/api/traccar' - -let __gpsStarted = false -let __gpsInterval = null -let __gpsPolling = false - -export function useGpsTracking (technicians) { - const traccarDevices = ref([]) - const _techsByDevice = {} - - function _buildTechDeviceMap () { - Object.keys(_techsByDevice).forEach(k => delete _techsByDevice[k]) - technicians.value.forEach(t => { - if (!t.traccarDeviceId) return - const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId) - if (dev) _techsByDevice[dev.id] = t - }) - } - - function _applyPositions (positions) { - positions.forEach(p => { - const tech = _techsByDevice[p.deviceId] - if (!tech || !p.latitude || !p.longitude) return - const cur = tech.gpsCoords - if (!cur || Math.abs(cur[0] - p.longitude) > 0.00001 || Math.abs(cur[1] - p.latitude) > 0.00001) { - tech.gpsCoords = [p.longitude, p.latitude] - } - tech.gpsSpeed = p.speed || 0 - tech.gpsTime = p.fixTime - tech.gpsOnline = true - }) - } - - async function pollGps () { - if (__gpsPolling) return - __gpsPolling = true - try { - if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices() - _buildTechDeviceMap() - const deviceIds = Object.keys(_techsByDevice).map(Number) - if (!deviceIds.length) return - const positions = await fetchPositions(deviceIds) - _applyPositions(positions) - // Mark techs with no position as offline - Object.values(_techsByDevice).forEach(t => { - if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false - }) - } catch { /* poll error */ } - finally { __gpsPolling = false } - } - - async function startGpsTracking () { - if (__gpsStarted) return - __gpsStarted = true - await pollGps() - __gpsInterval = setInterval(pollGps, 30000) - } - - function stopGpsTracking () { - __gpsStarted = false - if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null } - } - - return { traccarDevices, pollGps, startGpsTracking, stopGpsTracking } -} diff --git a/apps/ops/src/composables/useJobOffers.js b/apps/ops/src/composables/useJobOffers.js deleted file mode 100644 index 3b5b01b..0000000 --- a/apps/ops/src/composables/useJobOffers.js +++ /dev/null @@ -1,302 +0,0 @@ -// ── Uber-style job offer pool ──────────────────────────────────────────────── -// Manages the offer lifecycle: create → broadcast → accept/decline → assign -// Supports both push (dispatcher sends to specific techs) and pull (tech picks from pool) -// Pricing: rush/overtime jobs carry displacement fee + hourly rate -// ───────────────────────────────────────────────────────────────────────────── -import { ref, computed } from 'vue' -import { fetchActiveOffers, createOffer, acceptOffer, declineOffer, cancelOffer } from 'src/api/offers' -import { updateJob } from 'src/api/dispatch' -import { sendTestSms } from 'src/api/sms' -import { localDateStr, techDaySchedule, techDayCapacityH, timeToH } from './useHelpers' - -// ── Pricing presets ────────────────────────────────────────────────────────── -export const PRICING_PRESETS = { - rush_weekend: { - label: 'Rush fin de semaine', - displacement: 150, - hourlyRate: 125, - currency: 'CAD', - description: 'Déplacement minimum 150$ + 125$/h', - }, - rush_evening: { - label: 'Rush soirée', - displacement: 100, - hourlyRate: 100, - currency: 'CAD', - description: 'Déplacement minimum 100$ + 100$/h', - }, - rush_holiday: { - label: 'Rush jour férié', - displacement: 200, - hourlyRate: 150, - currency: 'CAD', - description: 'Déplacement minimum 200$ + 150$/h', - }, - standard: { - label: 'Tarif régulier', - displacement: 0, - hourlyRate: 0, - currency: 'CAD', - description: 'Inclus dans le plan de service', - }, -} - -export function useJobOffers (store) { - const offers = ref([]) - const loadingOffers = ref(false) - const showOfferPool = ref(false) - - // ── Map raw ERPNext doc → local offer object ────────────────────────────── - function _mapOffer (o) { - return { - id: o.name, - name: o.name, - jobName: o.job_name || null, // linked Dispatch Job - subject: o.subject || '', - address: o.address || '', - customer: o.customer || '', - scheduledDate: o.scheduled_date || null, - startTime: o.start_time || null, - duration: parseFloat(o.duration_h) || 1, - priority: o.priority || 'medium', - status: o.status || 'open', // open | pending | accepted | expired | cancelled - // Targeting - offerMode: o.offer_mode || 'broadcast', // broadcast | targeted | pool - targetTechs: o.target_techs ? JSON.parse(o.target_techs) : [], // specific tech IDs - requiredTags: o.required_tags ? JSON.parse(o.required_tags) : [], - // Responses - acceptedBy: o.accepted_by || null, - acceptedAt: o.accepted_at || null, - declinedTechs: o.declined_techs ? JSON.parse(o.declined_techs) : [], - // Pricing - pricingPreset: o.pricing_preset || 'standard', - displacement: parseFloat(o.displacement_fee) || 0, - hourlyRate: parseFloat(o.hourly_rate) || 0, - currency: o.currency || 'CAD', - // Customer checkout - isCustomerRequest: !!o.is_customer_request, - salesOrder: o.sales_order || null, - // Timing - expiresAt: o.expires_at || null, - createdAt: o.creation || null, - // Source - orderSource: o.order_source || 'dispatch', // dispatch | portal | api - } - } - - // ── Load active offers ──────────────────────────────────────────────────── - async function loadOffers () { - loadingOffers.value = true - try { - const raw = await fetchActiveOffers() - offers.value = raw.map(_mapOffer) - } catch (e) { - console.warn('[offers] load failed:', e.message) - } finally { - loadingOffers.value = false - } - } - - // ── Active offers count (for badge) ─────────────────────────────────────── - const activeOfferCount = computed(() => offers.value.filter(o => o.status === 'open' || o.status === 'pending').length) - - // ── Find matching techs for an offer ────────────────────────────────────── - function matchingTechs (offer) { - if (!store.technicians?.length) return [] - return store.technicians.filter(tech => { - // Skip inactive / absent - if (tech.status === 'off' || tech.status === 'inactive') return false - // Skip already declined - if (offer.declinedTechs.some(d => d.techId === tech.id)) return false - // Check required tags/skills - if (offer.requiredTags.length) { - const hasAll = offer.requiredTags.every(reqTag => { - const techTag = tech.tagsWithLevel?.find(t => t.tag === reqTag.tag || t.tag === reqTag) - if (!techTag) return false - if (reqTag.level && techTag.level < reqTag.level) return false - return true - }) - if (!hasAll) return false - } - // Check availability on scheduled date - if (offer.scheduledDate) { - const sched = techDaySchedule(tech, offer.scheduledDate) - // For rush/overtime: tech doesn't need to be on schedule - if (!sched && offer.pricingPreset === 'standard') return false - // Check capacity (allow overflow for rush) - if (sched && offer.pricingPreset === 'standard') { - const cap = techDayCapacityH(tech, offer.scheduledDate) - const load = tech.queue - .filter(j => j.scheduledDate === offer.scheduledDate) - .reduce((sum, j) => sum + (parseFloat(j.duration) || 0), 0) - if (load + offer.duration > cap * 1.2) return false // 20% overflow tolerance for standard - } - } - // Targeted mode: only include specified techs - if (offer.offerMode === 'targeted' && offer.targetTechs.length) { - if (!offer.targetTechs.includes(tech.id)) return false - } - return true - }) - } - - // ── Create and broadcast an offer ───────────────────────────────────────── - async function broadcastOffer (offerData, notifyViaSms = false) { - const pricing = PRICING_PRESETS[offerData.pricingPreset] || PRICING_PRESETS.standard - const payload = { - subject: offerData.subject, - address: offerData.address || '', - customer: offerData.customer || '', - scheduled_date: offerData.scheduledDate || '', - start_time: offerData.startTime || '', - duration_h: offerData.duration || 1, - priority: offerData.priority || 'medium', - status: 'open', - offer_mode: offerData.offerMode || 'broadcast', - target_techs: JSON.stringify(offerData.targetTechs || []), - required_tags: JSON.stringify(offerData.requiredTags || []), - pricing_preset: offerData.pricingPreset || 'standard', - displacement_fee: pricing.displacement, - hourly_rate: pricing.hourlyRate, - currency: pricing.currency, - is_customer_request: offerData.isCustomerRequest ? 1 : 0, - sales_order: offerData.salesOrder || '', - order_source: offerData.orderSource || 'dispatch', - expires_at: offerData.expiresAt || '', - job_name: offerData.jobName || '', - } - const doc = await createOffer(payload) - const mapped = _mapOffer(doc) - offers.value.unshift(mapped) - - // Notify matching techs via SMS - if (notifyViaSms) { - const techs = matchingTechs(mapped) - const fmtPrice = pricing.displacement > 0 - ? `💰 ${pricing.displacement}$ déplacement + ${pricing.hourlyRate}$/h` - : '' - for (const tech of techs) { - if (!tech.phone) continue - const msg = [ - `📋 Nouvelle offre de travail:`, - `${mapped.subject}`, - mapped.address ? `📍 ${mapped.address}` : '', - mapped.scheduledDate ? `📅 ${mapped.scheduledDate}${mapped.startTime ? ' à ' + mapped.startTime : ''}` : '', - `⏱ ${mapped.duration}h`, - fmtPrice, - ``, - `Répondez OUI pour accepter ou NON pour décliner.`, - ].filter(Boolean).join('\n') - sendTestSms(tech.phone, msg, mapped.customer, { - reference_doctype: 'Dispatch Offer', - reference_name: mapped.id, - }).catch(err => console.warn(`[offer SMS] ${tech.id}:`, err.message)) - } - } - - return mapped - } - - // ── Accept an offer (tech side) ─────────────────────────────────────────── - async function handleAccept (offerId, techId) { - const offer = offers.value.find(o => o.id === offerId) - if (!offer) throw new Error('Offer not found') - // Update offer status - await acceptOffer(offerId, techId) - offer.status = 'accepted' - offer.acceptedBy = techId - - // Create or assign the dispatch job - if (offer.jobName) { - // Existing job — assign to accepting tech - await store.assignJobToTech(offer.jobName, techId, - store.technicians.find(t => t.id === techId)?.queue.length || 0, - offer.scheduledDate) - } else { - // Create new job from offer - const job = await store.createJob({ - subject: offer.subject, - address: offer.address, - duration_h: offer.duration, - priority: offer.priority, - scheduled_date: offer.scheduledDate, - start_time: offer.startTime, - assigned_tech: techId, - customer: offer.customer, - sales_order: offer.salesOrder, - order_source: offer.orderSource, - }) - // Link job back to offer - if (job?.name) { - await import('src/api/offers').then(m => m.updateOffer(offerId, { job_name: job.name })) - offer.jobName = job.name - } - } - - return offer - } - - // ── Decline an offer (tech side) ────────────────────────────────────────── - async function handleDecline (offerId, techId, reason = '') { - const offer = offers.value.find(o => o.id === offerId) - if (!offer) return - await declineOffer(offerId, techId, reason) - offer.declinedTechs.push({ techId, reason, at: new Date().toISOString() }) - // Check if all targeted techs declined → auto-expire - if (offer.offerMode === 'targeted' && offer.targetTechs.length) { - const allDeclined = offer.targetTechs.every(tid => - offer.declinedTechs.some(d => d.techId === tid) - ) - if (allDeclined) { - await cancelOffer(offerId) - offer.status = 'expired' - } - } - } - - // ── Cancel an offer (dispatcher side) ───────────────────────────────────── - async function handleCancel (offerId) { - const offer = offers.value.find(o => o.id === offerId) - if (!offer) return - await cancelOffer(offerId) - offer.status = 'cancelled' - } - - // ── Estimate cost for a rush job ────────────────────────────────────────── - function estimateCost (preset, durationH) { - const p = PRICING_PRESETS[preset] || PRICING_PRESETS.standard - return { - displacement: p.displacement, - labour: Math.ceil(durationH * p.hourlyRate * 100) / 100, - total: p.displacement + Math.ceil(durationH * p.hourlyRate * 100) / 100, - currency: p.currency, - description: p.description, - } - } - - // ── Create offer from existing unassigned job ───────────────────────────── - async function offerExistingJob (job, opts = {}) { - return broadcastOffer({ - jobName: job.name || job.id, - subject: job.subject, - address: job.address, - customer: job.customer, - scheduledDate: job.scheduledDate, - startTime: job.startTime, - duration: job.duration, - priority: job.priority, - pricingPreset: opts.pricingPreset || 'standard', - offerMode: opts.offerMode || 'broadcast', - targetTechs: opts.targetTechs || [], - requiredTags: opts.requiredTags || [], - orderSource: 'dispatch', - expiresAt: opts.expiresAt || '', - }, opts.sms !== false) - } - - return { - offers, loadingOffers, showOfferPool, activeOfferCount, - loadOffers, broadcastOffer, handleAccept, handleDecline, handleCancel, - matchingTechs, estimateCost, offerExistingJob, - } -} diff --git a/apps/ops/src/composables/useResourceFilter.js b/apps/ops/src/composables/useResourceFilter.js deleted file mode 100644 index 817e030..0000000 --- a/apps/ops/src/composables/useResourceFilter.js +++ /dev/null @@ -1,130 +0,0 @@ -import { ref, computed, watch } from 'vue' - -export function useResourceFilter (store, opts = {}) { - const selectedResIds = ref(JSON.parse(localStorage.getItem('sbv2-resIds') || '[]')) - const filterStatus = ref(localStorage.getItem('sbv2-filterStatus') || '') - const filterGroup = ref(localStorage.getItem('sbv2-filterGroup') || '') - const filterTags = ref(JSON.parse(localStorage.getItem('sbv2-filterTags') || '[]')) - // Default 'human' = show only techs. Materials are secondary and - // accessed via the resource-type dropdown when needed. Once the user - // explicitly picks something else (incl. ''), it's persisted. - const filterResourceType = ref(localStorage.getItem('sbv2-filterResType') ?? 'human') // '' | 'human' | 'material' - const searchQuery = ref('') - const techSort = ref(localStorage.getItem('sbv2-techSort') || 'default') - const manualOrder = ref(JSON.parse(localStorage.getItem('sbv2-techOrder') || '[]')) - const resSelectorOpen = ref(false) - const tempSelectedIds = ref([]) - const dragReorderTech = ref(null) - const hideAbsent = ref(false) // Quick toggle: hide techs absent on current day - - watch(selectedResIds, v => localStorage.setItem('sbv2-resIds', JSON.stringify(v)), { deep: true }) - watch(filterStatus, v => localStorage.setItem('sbv2-filterStatus', v)) - watch(filterGroup, v => localStorage.setItem('sbv2-filterGroup', v)) - watch(filterResourceType, v => localStorage.setItem('sbv2-filterResType', v)) - watch(techSort, v => localStorage.setItem('sbv2-techSort', v)) - - // Unique groups extracted from technicians - const availableGroups = computed(() => { - const groups = new Set() - for (const t of store.technicians) { - if (t.group) groups.add(t.group) - } - return [...groups].sort() - }) - - // Resource categories for material resources - const availableCategories = computed(() => { - const cats = new Set() - for (const t of store.technicians) { - if (t.resourceCategory) cats.add(t.resourceCategory) - } - return [...cats].sort() - }) - - // Count by type - const humanCount = computed(() => store.technicians.filter(t => t.resourceType !== 'material').length) - const materialCount = computed(() => store.technicians.filter(t => t.resourceType === 'material').length) - - const showInactive = ref(false) - - const filteredResources = computed(() => { - let list = store.technicians - // Hide permanently inactive techs unless explicitly showing them or filtering for them - // Temporarily absent (off) techs stay visible so absence blocks render on the timeline - if (!showInactive.value && filterStatus.value !== 'inactive') list = list.filter(t => t.status !== 'inactive') - if (filterResourceType.value) list = list.filter(t => (t.resourceType || 'human') === filterResourceType.value) - if (searchQuery.value) { const q = searchQuery.value.toLowerCase(); list = list.filter(t => t.fullName.toLowerCase().includes(q)) } - if (filterStatus.value) list = list.filter(t => (t.status||'available') === filterStatus.value) - if (filterGroup.value) list = list.filter(t => t.group === filterGroup.value) - if (selectedResIds.value.length) list = list.filter(t => selectedResIds.value.includes(t.id)) - if (filterTags.value.length) list = list.filter(t => filterTags.value.every(ft => (t.tags||[]).includes(ft))) - // Quick toggle: hide techs absent on the current viewed day - if (hideAbsent.value && opts.isAbsentOnDay) list = list.filter(t => !opts.isAbsentOnDay(t)) - // Sort: humans first, then material; within each, apply chosen sort - list = [...list].sort((a, b) => { - const aType = a.resourceType === 'material' ? 1 : 0 - const bType = b.resourceType === 'material' ? 1 : 0 - if (aType !== bType) return aType - bType - if (techSort.value === 'alpha') return a.fullName.split(' ').pop().toLowerCase().localeCompare(b.fullName.split(' ').pop().toLowerCase()) - if (techSort.value === 'load' && opts.getLoadH) return (opts.getLoadH(a) || 0) - (opts.getLoadH(b) || 0) - if (techSort.value === 'manual' && manualOrder.value.length) { - const order = manualOrder.value - return (order.indexOf(a.id) === -1 ? 999 : order.indexOf(a.id)) - (order.indexOf(b.id) === -1 ? 999 : order.indexOf(b.id)) - } - return 0 - }) - return list - }) - - // Resources grouped by tech_group for visual grouping - const groupedResources = computed(() => { - const groups = new Map() - for (const t of filteredResources.value) { - const g = t.group || '' - if (!groups.has(g)) groups.set(g, []) - groups.get(g).push(t) - } - // Sort: named groups first (alphabetically), then ungrouped last - const sorted = [...groups.entries()].sort((a, b) => { - if (!a[0] && b[0]) return 1 - if (a[0] && !b[0]) return -1 - return a[0].localeCompare(b[0]) - }) - return sorted.map(([name, techs]) => ({ name, label: name || 'Non assigné', techs })) - }) - - function onTechReorderStart (e, tech) { - dragReorderTech.value = tech - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', 'reorder') - } - function onTechReorderDrop (e, targetTech) { - e.preventDefault() - if (!dragReorderTech.value || dragReorderTech.value.id === targetTech.id) { dragReorderTech.value = null; return } - techSort.value = 'manual' - const ids = filteredResources.value.map(t => t.id) - const fromIdx = ids.indexOf(dragReorderTech.value.id), toIdx = ids.indexOf(targetTech.id) - ids.splice(fromIdx, 1); ids.splice(toIdx, 0, dragReorderTech.value.id) - manualOrder.value = ids; localStorage.setItem('sbv2-techOrder', JSON.stringify(ids)) - dragReorderTech.value = null - } - - function openResSelector () { tempSelectedIds.value = [...selectedResIds.value]; resSelectorOpen.value = true } - function applyResSelector () { selectedResIds.value = [...tempSelectedIds.value]; resSelectorOpen.value = false } - function toggleTempRes (id) { - const idx = tempSelectedIds.value.indexOf(id) - if (idx >= 0) tempSelectedIds.value.splice(idx, 1); else tempSelectedIds.value.push(id) - } - function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; filterGroup.value = ''; filterResourceType.value = ''; searchQuery.value = ''; filterTags.value = []; showInactive.value = false; hideAbsent.value = false; localStorage.removeItem('sbv2-filterTags'); localStorage.removeItem('sbv2-filterGroup'); localStorage.removeItem('sbv2-filterResType') } - - // Count of inactive techs (for UI indicator) - const inactiveCount = computed(() => store.technicians.filter(t => !t.active).length) - - return { - selectedResIds, filterStatus, filterGroup, filterResourceType, filterTags, searchQuery, techSort, manualOrder, - showInactive, hideAbsent, inactiveCount, humanCount, materialCount, availableCategories, - filteredResources, groupedResources, availableGroups, resSelectorOpen, tempSelectedIds, dragReorderTech, - openResSelector, applyResSelector, toggleTempRes, clearFilters, - onTechReorderStart, onTechReorderDrop, - } -} diff --git a/apps/ops/src/composables/useScheduler.js b/apps/ops/src/composables/useScheduler.js deleted file mode 100644 index aebe341..0000000 --- a/apps/ops/src/composables/useScheduler.js +++ /dev/null @@ -1,389 +0,0 @@ -// ── Scheduling logic: timeline computation, route cache, job placement ─────── -import { ref, computed } from 'vue' -import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate, techDaySchedule, techDayCapacityH, expandRRule } from './useHelpers' -import { ABSENCE_REASONS } from './useTechManagement' - -export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) { - // Day view: 6AM–6PM. Week view: 7AM–8PM. - const H_START = computed(() => currentView.value === 'day' ? 6 : 7) - const H_END = computed(() => currentView.value === 'day' ? 18 : 20) - - // ── Route cache ──────────────────────────────────────────────────────────── - const routeLegs = ref({}) - const routeGeometry = ref({}) - - // ── Ghost occurrences from recurring templates ───────────────────────────── - function ghostOccurrencesForDate (tech, dateStr) { - const templates = tech.queue.filter(j => j.isRecurring && j.recurrenceRule) - if (!templates.length) return [] - const ghosts = [] - for (const tpl of templates) { - const rangeEnd = tpl.recurrenceEnd || localDateStr((() => { const d = new Date(); d.setDate(d.getDate() + 90); return d })()) - const dates = expandRRule(tpl.recurrenceRule, tpl.scheduledDate, dateStr, dateStr, tpl.pausePeriods || []) - if (!dates.includes(dateStr)) continue - // Skip if dateStr is the template's own scheduledDate (already rendered as real job) - if (dateStr === tpl.scheduledDate) continue - // Skip if a materialized instance already exists for this date - const hasMaterialized = tech.queue.some(j => j.templateId === tpl.id && j.scheduledDate === dateStr) - if (hasMaterialized) continue - // Skip tech off-days (unless continuous) - if (!tpl.continuous && !techDaySchedule(tech, dateStr)) continue - ghosts.push({ - ...tpl, - id: `ghost-${tpl.id}-${dateStr}`, - _realId: tpl.id, - scheduledDate: dateStr, - endDate: null, - _isGhost: true, - _templateJob: tpl, - }) - } - return ghosts - } - - // ── Parent start position cache ──────────────────────────────────────────── - let _parentStartCache = {} - - function getParentStartH (job) { - if (!store.technicians.length) return job.startHour ?? 8 - const key = `${job.assignedTech}||${job.id}` - if (_parentStartCache[key] !== undefined) return _parentStartCache[key] - const leadTech = store.technicians.find(t => t.id === job.assignedTech) - if (!leadTech) return job.startHour ?? 8 - const dayStr = localDateStr(periodStart.value) - const leadJobs = sortJobsByTime(leadTech.queue.filter(j => getJobDate(j.id) === dayStr)) - const cacheKey = `${leadTech.id}||${dayStr}` - const legMins = routeLegs.value[cacheKey] - const hasHome = !!(leadTech.coords?.[0] && leadTech.coords?.[1]) - const _parentSched = techDaySchedule(leadTech, dayStr) - let cursor = _parentSched ? _parentSched.startH : 8, result = job.startHour ?? cursor - leadJobs.forEach((j, idx) => { - const showTravel = idx > 0 || (idx === 0 && hasHome) - if (showTravel) { - const legIdx = hasHome ? idx : idx - 1 - const routeMin = legMins?.[legIdx] - cursor += (routeMin != null ? routeMin : (parseFloat(j.legDur) > 0 ? parseFloat(j.legDur) : 20)) / 60 - } - const pinnedH = j.startTime ? timeToH(j.startTime) : null - const startH = pinnedH ?? cursor - if (j.id === job.id) result = startH - cursor = startH + (parseFloat(j.duration) || 1) - }) - _parentStartCache[key] = result - return result - } - - // ── All jobs for a tech on a date (primary + assists) ────────────────────── - function techAllJobsForDate (tech, dateStr) { - _parentStartCache = {} - const primary = tech.queue.filter(j => jobSpansDate(j, dateStr, tech)) - const assists = (tech.assistJobs || []) - .filter(j => jobSpansDate(j, dateStr, tech)) - .map(j => { - const a = j.assistants.find(x => x.techId === tech.id) - const parentH = getParentStartH(j) - return { - ...j, - duration: a?.duration || j.duration, - startTime: hToTime(parentH), - startHour: parentH, - _isAssist: true, - _assistPinned: !!a?.pinned, - _assistNote: a?.note || '', - _parentJob: j, - } - }) - const ghosts = ghostOccurrencesForDate(tech, dateStr) - return sortJobsByTime([...primary, ...assists, ...ghosts]) - } - - // ── Absence / schedule-off segments for a tech on a given date ─────────────── - function absenceSegmentsForDate (tech, dateStr) { - const segs = [] - - // 1. Explicit absence (vacation, sick, etc.) - if (tech.status === 'off' && tech.absenceFrom) { - const from = tech.absenceFrom - const until = tech.absenceUntil || from - if (dateStr >= from && dateStr <= until) { - const startH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : H_START.value - const endH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : H_END.value - const reasonObj = ABSENCE_REASONS.find(r => r.value === tech.absenceReason) || { label: 'Absent', icon: '⏸' } - const left = (startH - H_START.value) * pxPerHr.value - const width = (endH - startH) * pxPerHr.value - segs.push({ - type: 'absence', startH, endH, - reason: tech.absenceReason, reasonLabel: reasonObj.label, reasonIcon: reasonObj.icon, - from: tech.absenceFrom, until: tech.absenceUntil, techId: tech.id, - style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' }, - job: { id: `absence-${tech.id}-${dateStr}` }, - }) - return segs // absence covers the day — skip schedule check - } - } - - // 2. Weekly schedule off-day (regular day off like Fridays for 4×10 schedule) - // Marked as _isDayOff so TimelineRow can hide it when planning mode is off - const daySched = techDaySchedule(tech, dateStr) - if (!daySched) { - const left = 0 - const width = (H_END.value - H_START.value) * pxPerHr.value - segs.push({ - type: 'absence', startH: H_START.value, endH: H_END.value, - reason: 'day_off', reasonLabel: 'Jour de repos', reasonIcon: '📅', - _isDayOff: true, - from: null, until: null, techId: tech.id, - style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' }, - job: { id: `schedoff-${tech.id}-${dateStr}` }, - }) - } - - return segs - } - - // ── Day view: schedule blocks with pinned anchors + auto-flow ────────────── - function techDayJobsWithTravel (tech) { - _checkCacheInvalidation() - const dayStr = localDateStr(periodStart.value) - const segKey = `${tech.id}||${dayStr}||${tech.queue.length}||${(tech.assistJobs||[]).length}` - if (_daySegmentsCache.has(segKey)) return _daySegmentsCache.get(segKey) - const cacheKey = `${tech.id}||${dayStr}` - const legMins = routeLegs.value[cacheKey] - const hasHome = !!(tech.coords?.[0] && tech.coords?.[1]) - const allJobs = techAllJobsForDate(tech, dayStr) - - const flowEntries = [] - const floatingEntries = [] - allJobs.forEach(job => { - const isAssist = !!job._isAssist - const dur = parseFloat(job.duration) || 1 - const isPinned = isAssist ? !!job._assistPinned : !!getJobTime(job.id) - const pinH = isAssist ? job.startHour : (getJobTime(job.id) ? timeToH(getJobTime(job.id)) : null) - const entry = { job, dur, isAssist, isPinned, pinH } - if (isAssist && !job._assistPinned) floatingEntries.push(entry) - else flowEntries.push(entry) - }) - - // Absence blocks act as occupied time (prevent auto-placing jobs there) - const absSegs = absenceSegmentsForDate(tech, dayStr) - const absOccupied = absSegs.map(s => ({ start: s.startH, end: s.endH })) - - const pinnedAnchors = flowEntries.filter(e => e.isPinned).map(e => ({ start: e.pinH, end: e.pinH + e.dur })) - const placed = [] - const occupied = [...pinnedAnchors.map(a => ({ ...a })), ...absOccupied] - const sortedFlow = [...flowEntries].sort((a, b) => { - if (a.isPinned && b.isPinned) return a.pinH - b.pinH - if (a.isPinned) return -1 - if (b.isPinned) return 1 - return 0 - }) - - sortedFlow.filter(e => e.isPinned).forEach(e => placed.push({ entry: e, startH: e.pinH })) - - const daySched = techDaySchedule(tech, dayStr) - let cursor = daySched ? daySched.startH : 8, flowIdx = 0 - sortedFlow.filter(e => !e.isPinned).forEach(e => { - const legIdx = hasHome ? flowIdx : flowIdx - 1 - const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0] - const travelH = (routeMin != null ? routeMin : (parseFloat(e.job.legDur) > 0 ? parseFloat(e.job.legDur) : 20)) / 60 - let startH = cursor + (flowIdx > 0 || hasHome ? travelH : 0) - let safe = false - while (!safe) { - const endH = startH + e.dur - const overlap = occupied.find(o => startH < o.end && endH > o.start) - if (overlap) startH = overlap.end + travelH - else safe = true - } - placed.push({ entry: e, startH }) - occupied.push({ start: startH, end: startH + e.dur }) - cursor = startH + e.dur - flowIdx++ - }) - - // Inject absence blocks as pseudo-placed entries so travel chains around them - absSegs.forEach(s => { - placed.push({ entry: { job: s.job, dur: s.endH - s.startH, isAssist: false, isPinned: true, pinH: s.startH, _isAbsence: true, _absSeg: s }, startH: s.startH }) - }) - - placed.sort((a, b) => a.startH - b.startH) - - const result = [] - - // Shift availability background block (renders behind jobs) - if (daySched) { - const shiftLeft = (daySched.startH - H_START.value) * pxPerHr.value - const shiftWidth = (daySched.endH - daySched.startH) * pxPerHr.value - result.push({ - type: 'shift', startH: daySched.startH, endH: daySched.endH, - label: `${daySched.start} – ${daySched.end}`, - style: { left: shiftLeft + 'px', width: Math.max(18, shiftWidth) + 'px', top: '0', bottom: '0', position: 'absolute' }, - }) - } - - // Extra shifts (on-call, garde) from tech.extraShifts - const extras = (tech.extraShifts || []).filter(s => { - if (!s.rrule || !s.startTime || !s.endTime) return false - const dates = expandRRule(s.rrule, s.from || tech.scheduledDate || dayStr, dayStr, dayStr, []) - return dates.includes(dayStr) - }) - extras.forEach(s => { - const sH = timeToH(s.startTime), eH = timeToH(s.endTime) - result.push({ - type: 'shift', startH: sH, endH: eH, - label: `${s.label || 'Garde'}: ${s.startTime} – ${s.endTime}`, - isOnCall: true, - style: { left: (sH - H_START.value) * pxPerHr.value + 'px', width: Math.max(18, (eH - sH) * pxPerHr.value) + 'px', top: '0', bottom: '0', position: 'absolute' }, - }) - }) - - let prevEndH = null - let legCounter = 0 - placed.forEach((p) => { - const { entry, startH } = p - const { job, dur, isAssist, isPinned, _isAbsence, _absSeg } = entry - - // Travel gap between previous block end and this block start - const travelStart = prevEndH ?? (hasHome ? 8 : null) - if (travelStart != null && startH > travelStart + 0.01 && !_isAbsence) { - const gapH = startH - travelStart - const realJob = isAssist ? job._parentJob : job - const legIdx = hasHome ? legCounter : legCounter - 1 - const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0] - const fromRoute = routeMin != null - result.push({ - type: 'travel', job: realJob, travelMin: fromRoute ? routeMin : Math.round(gapH * 60), fromRoute, isAssist: false, - style: { left: (travelStart - H_START.value) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' }, - color: jobColorFn(realJob), - }) - legCounter++ - } - - if (_isAbsence) { - // Render as absence block - result.push(_absSeg) - } else { - const realJob = isAssist ? job._parentJob : job - const jLeft = (startH - H_START.value) * pxPerHr.value - const jWidth = Math.max(18, dur * pxPerHr.value) - result.push({ - type: isAssist ? 'assist' : 'job', job: realJob, - pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist, - assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null, - assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null, - _isGhost: !!job._isGhost, _templateJob: job._templateJob || null, - style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' }, - }) - } - prevEndH = startH + dur - }) - - floatingEntries.forEach(entry => { - const { job, dur } = entry - const startH = job.startHour ?? 8 - result.push({ - type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true, - assistPinned: false, assistDur: dur, assistNote: job._assistNote, assistTechId: tech.id, - style: { left: (startH - H_START.value) * pxPerHr.value + 'px', width: Math.max(18, dur * pxPerHr.value) + 'px', top: '52%', bottom: '4px', position: 'absolute' }, - }) - }) - - _daySegmentsCache.set(segKey, result) - return result - } - - // ── Week view helpers ────────────────────────────────────────────────────── - function techBookingsByDay (tech) { - return dayColumns.value.map(d => { - const ds = localDateStr(d) - const primary = tech.queue.filter(j => jobSpansDate(j, ds, tech)) - const assists = (tech.assistJobs || []) - .filter(j => jobSpansDate(j, ds, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned) - .map(j => ({ ...j, _isAssistChip: true, _assistDur: j.assistants.find(a => a.techId === tech.id)?.duration || j.duration })) - const ghosts = ghostOccurrencesForDate(tech, ds) - const absSegs = absenceSegmentsForDate(tech, ds) - return { day: d, dateStr: ds, jobs: [...primary, ...assists, ...ghosts], absent: absSegs.length > 0, absenceInfo: absSegs[0] || null } - }) - } - - // ── Memoization caches (cleared on reactive dependency changes) ────────── - const _periodLoadCache = new Map() - const _periodCapCache = new Map() - const _daySegmentsCache = new Map() - - // Invalidate caches when period/view changes - let _lastCacheKey = '' - function _checkCacheInvalidation () { - const key = `${currentView.value}||${periodStart.value}||${dayColumns.value.length}||${store.jobs.length}||${store.jobVersion}` - if (key !== _lastCacheKey) { - _lastCacheKey = key - _periodLoadCache.clear() - _periodCapCache.clear() - _daySegmentsCache.clear() - } - } - - function periodLoadH (tech) { - _checkCacheInvalidation() - if (_periodLoadCache.has(tech.id)) return _periodLoadCache.get(tech.id) - const dateSet = new Set(dayColumns.value.map(d => localDateStr(d))) - let total = tech.queue.reduce((sum, j) => { - const ds = getJobDate(j.id) - return ds && dateSet.has(ds) ? sum + (parseFloat(j.duration) || 0) : sum - }, 0) - ;(tech.assistJobs || []).forEach(j => { - const ds = getJobDate(j.id) - if (ds && dateSet.has(ds)) { - const a = j.assistants.find(x => x.techId === tech.id) - if (a?.pinned) total += parseFloat(a?.duration || j.duration) || 0 - } - }) - // Include absence hours as occupied time - dayColumns.value.forEach(d => { - const ds = localDateStr(d) - const absSegs = absenceSegmentsForDate(tech, ds) - absSegs.forEach(s => { total += s.endH - s.startH }) - }) - _periodLoadCache.set(tech.id, total) - return total - } - - // Per-tech capacity for the visible period (for utilization bar denominator) - function techPeriodCapacityH (tech) { - _checkCacheInvalidation() - if (_periodCapCache.has(tech.id)) return _periodCapCache.get(tech.id) - let total = 0 - dayColumns.value.forEach(d => { - total += techDayCapacityH(tech, localDateStr(d)) - }) - const result = total || 8 // fallback to avoid /0 - _periodCapCache.set(tech.id, result) - return result - } - - // Per-tech end hour for current day (for capacity line position) - function techDayEndH (tech) { - const dayStr = localDateStr(periodStart.value) - const sched = techDaySchedule(tech, dayStr) - return sched ? sched.endH : 16 - } - - function techsActiveOnDay (dateStr, resources) { - return resources.filter(tech => - tech.queue.some(j => jobSpansDate(j, dateStr, tech)) || - (tech.assistJobs || []).some(j => jobSpansDate(j, dateStr, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned) - ) - } - - function dayJobCount (dateStr, resources) { - const jobIds = new Set() - resources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr, t)).forEach(j => jobIds.add(j.id))) - return jobIds.size - } - - return { - H_START, H_END, routeLegs, routeGeometry, - techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, ghostOccurrencesForDate, - techBookingsByDay, periodLoadH, techPeriodCapacityH, techDayEndH, - techsActiveOnDay, dayJobCount, - } -} diff --git a/apps/ops/src/composables/useSelection.js b/apps/ops/src/composables/useSelection.js deleted file mode 100644 index 3e3a922..0000000 --- a/apps/ops/src/composables/useSelection.js +++ /dev/null @@ -1,172 +0,0 @@ -// ── Selection composable: lasso, multi-select, hover linking, batch ops ─────── -import { ref, computed } from 'vue' -import { localDateStr } from './useHelpers' - -export function useSelection (deps) { - const { store, periodStart, smartAssign, invalidateRoutes, fullUnassign } = deps - - const hoveredJobId = ref(null) - const selectedJob = ref(null) // { job, techId, isAssist?, assistTechId? } - const multiSelect = ref([]) // [{ job, techId, isAssist?, assistTechId? }] - - // ── Select / toggle ───────────────────────────────────────────────────────── - function selectJob (job, techId, isAssist = false, assistTechId = null, event = null, rightPanel = null) { - const entry = { job, techId, isAssist, assistTechId } - const isMulti = event && (event.ctrlKey || event.metaKey) - if (isMulti) { - const idx = multiSelect.value.findIndex(s => s.job.id === job.id && s.isAssist === isAssist) - if (idx >= 0) multiSelect.value.splice(idx, 1) - else multiSelect.value.push(entry) - selectedJob.value = entry - } else { - multiSelect.value = [] - const same = selectedJob.value?.job?.id === job.id && selectedJob.value?.isAssist === isAssist && selectedJob.value?.assistTechId === assistTechId - selectedJob.value = same ? null : entry - if (!same && rightPanel !== undefined) { - const tech = store.technicians.find(t => t.id === (techId || job.assignedTech)) - if (rightPanel !== null && typeof rightPanel === 'object' && 'value' in rightPanel) { - rightPanel.value = { mode: 'details', data: { job, tech: tech || null } } - } - } else if (rightPanel !== null && typeof rightPanel === 'object' && 'value' in rightPanel) { - rightPanel.value = null - } - } - } - - function isJobMultiSelected (jobId, isAssist = false) { - return multiSelect.value.some(s => s.job.id === jobId && s.isAssist === isAssist) - } - - // ── Batch ops (grouped undo) ────────────────────────────────────────────────── - function batchUnassign (pushUndo) { - if (!multiSelect.value.length) return - // Snapshot all jobs before unassign — single undo entry - const assignments = multiSelect.value.filter(s => !s.isAssist).map(s => ({ - jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder, - scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])] - })) - const prevQueues = {} - store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] }) - - multiSelect.value.forEach(s => { - if (s.isAssist && s.assistTechId) store.removeAssistant(s.job.id, s.assistTechId) - else store.fullUnassign(s.job.id) - }) - - if (pushUndo && assignments.length) { - pushUndo({ type: 'batchAssign', assignments, prevQueues }) - } - multiSelect.value = []; selectedJob.value = null - invalidateRoutes() - } - - function batchMoveTo (techId, dayStr, pushUndo) { - if (!multiSelect.value.length) return - const day = dayStr || localDateStr(periodStart.value) - const jobs = multiSelect.value.filter(s => !s.isAssist) - // Snapshot for grouped undo - const assignments = jobs.map(s => ({ - jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder, - scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])] - })) - const prevQueues = {} - store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] }) - - jobs.forEach(s => smartAssign(s.job, techId, day)) - - if (pushUndo && assignments.length) { - pushUndo({ type: 'batchAssign', assignments, prevQueues }) - } - multiSelect.value = []; selectedJob.value = null - invalidateRoutes() - } - - // ── Lasso ───────────────────────────────────────────────────────────────────── - const lasso = ref(null) - const boardScroll = ref(null) - - const lassoStyle = computed(() => { - if (!lasso.value) return {} - const l = lasso.value - return { - left: Math.min(l.x1, l.x2) + 'px', top: Math.min(l.y1, l.y2) + 'px', - width: Math.abs(l.x2 - l.x1) + 'px', height: Math.abs(l.y2 - l.y1) + 'px', - } - }) - - function startLasso (e) { - if (e.target.closest('.sb-block, .sb-chip, .sb-res-cell, .sb-travel-trail, button, input, select, a')) return - if (e.button !== 0) return - e.preventDefault() - if (!e.ctrlKey && !e.metaKey) { - if (selectedJob.value || multiSelect.value.length) { - selectedJob.value = null; multiSelect.value = [] - } - } - const rect = boardScroll.value.getBoundingClientRect() - const x = e.clientX - rect.left + boardScroll.value.scrollLeft - const y = e.clientY - rect.top + boardScroll.value.scrollTop - lasso.value = { x1: x, y1: y, x2: x, y2: y } - } - - function moveLasso (e) { - if (!lasso.value) return - e.preventDefault() - const rect = boardScroll.value.getBoundingClientRect() - lasso.value.x2 = e.clientX - rect.left + boardScroll.value.scrollLeft - lasso.value.y2 = e.clientY - rect.top + boardScroll.value.scrollTop - } - - function endLasso () { - if (!lasso.value) return - const l = lasso.value - const w = Math.abs(l.x2 - l.x1), h = Math.abs(l.y2 - l.y1) - if (w > 10 && h > 10) { - const boardRect = boardScroll.value.getBoundingClientRect() - const lassoLeft = Math.min(l.x1, l.x2) - boardScroll.value.scrollLeft + boardRect.left - const lassoTop = Math.min(l.y1, l.y2) - boardScroll.value.scrollTop + boardRect.top - const lassoRight = lassoLeft + w, lassoBottom = lassoTop + h - const blocks = boardScroll.value.querySelectorAll('.sb-block[data-job-id], .sb-chip') - const selected = [] - blocks.forEach(el => { - const r = el.getBoundingClientRect() - if (r.right > lassoLeft && r.left < lassoRight && r.bottom > lassoTop && r.top < lassoBottom) { - const jobId = el.dataset?.jobId - if (jobId) { - const job = store.jobs.find(j => j.id === jobId) - if (job) selected.push({ job, techId: job.assignedTech, isAssist: false, assistTechId: null }) - } - } - }) - if (selected.length) { - multiSelect.value = selected - if (selected.length === 1) selectedJob.value = selected[0] - } - } - lasso.value = null - } - - // ── Hover linking helpers ───────────────────────────────────────────────────── - function techHasLinkedJob (tech) { - const hId = hoveredJobId.value, sId = selectedJob.value?.job?.id - if (hId && (tech.assistJobs || []).some(j => j.id === hId)) return true - if (hId && tech.queue.some(j => j.id === hId)) return true - if (sId && !selectedJob.value?.isAssist && (tech.assistJobs || []).some(j => j.id === sId)) return true - if (sId && selectedJob.value?.isAssist && tech.queue.some(j => j.id === sId)) return true - return false - } - - function techIsHovered (tech) { - const hId = hoveredJobId.value - if (!hId) return false - const job = tech.queue.find(j => j.id === hId) - return job && job.assistants?.length > 0 - } - - return { - hoveredJobId, selectedJob, multiSelect, - selectJob, isJobMultiSelected, batchUnassign, batchMoveTo, - lasso, boardScroll, lassoStyle, startLasso, moveLasso, endLasso, - techHasLinkedJob, techIsHovered, - } -} diff --git a/apps/ops/src/composables/useUndo.js b/apps/ops/src/composables/useUndo.js deleted file mode 100644 index 24de03b..0000000 --- a/apps/ops/src/composables/useUndo.js +++ /dev/null @@ -1,78 +0,0 @@ -// ── Undo stack composable ──────────────────────────────────────────────────── -import { ref, nextTick } from 'vue' -import { updateJob } from 'src/api/dispatch' -import { serializeAssistants } from './useHelpers' - -export function useUndo (store, invalidateRoutes) { - const undoStack = ref([]) - - function pushUndo (action) { - undoStack.value.push(action) - if (undoStack.value.length > 30) undoStack.value.shift() - } - - // Restore a single job to its previous state (unassign from current tech, re-assign if it had one) - function _restoreJob (prev) { - const job = store.jobs.find(j => j.id === prev.jobId) - if (!job) return - // Remove from all tech queues first - store.technicians.forEach(t => { t.queue = t.queue.filter(q => q.id !== prev.jobId) }) - if (prev.techId) { - // Was assigned before — re-assign - store.assignJobToTech(prev.jobId, prev.techId, prev.routeOrder, prev.scheduledDate) - } else { - // Was unassigned before — just mark as open - job.assignedTech = null - job.status = 'open' - job.scheduledDate = prev.scheduledDate || null - updateJob(job.name || job.id, { assigned_tech: null, status: 'open', scheduled_date: prev.scheduledDate || '' }).catch(() => {}) - } - if (prev.assistants?.length) { - job.assistants = prev.assistants - updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) - } - } - - function performUndo () { - const action = undoStack.value.pop() - if (!action) return - - if (action.type === 'removeAssistant') { - store.addAssistant(action.jobId, action.techId) - nextTick(() => { - const job = store.jobs.find(j => j.id === action.jobId) - const a = job?.assistants.find(x => x.techId === action.techId) - if (a) { a.duration = action.duration; a.note = action.note } - updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) - }) - - } else if (action.type === 'optimizeRoute') { - const tech = store.technicians.find(t => t.id === action.techId) - if (tech) { - tech.queue = action.prevQueue - action.prevQueue.forEach((j, i) => { j.routeOrder = i }) - } - - } else if (action.type === 'autoDistribute') { - action.assignments.forEach(a => _restoreJob(a)) - if (action.prevQueues) { - store.technicians.forEach(t => { - if (action.prevQueues[t.id]) t.queue = action.prevQueues[t.id] - }) - } - - } else if (action.type === 'batchAssign') { - // Undo a multi-select drag — restore each job to previous state - action.assignments.forEach(a => _restoreJob(a)) - - } else if (action.type === 'unassignJob') { - _restoreJob(action) - } - - // Rebuild assistJobs on all techs - store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) }) - invalidateRoutes() - } - - return { undoStack, pushUndo, performUndo } -} diff --git a/apps/ops/src/features/workforce/api/assignment.js b/apps/ops/src/features/workforce/api/assignment.js deleted file mode 100644 index ca7efa0..0000000 --- a/apps/ops/src/features/workforce/api/assignment.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * API d'assignation UNIFIÉE — chemin d'écriture UNIQUE (via targo-hub). - * ----------------------------------------------------------------------------- - * CONVERGENCE Dispatch ⇄ Planification : l'assignation job→tech - * (`assigned_tech` / `scheduled_date` / `route_order` / `start_time` / `status` - * + l'ÉQUIPE) ne doit JAMAIS être écrite en direct dans ERPNext (PUT - * api/dispatch.js). Tout passe ici → le hub (lib/roster.js) qui valide, pose le - * first-fit `start_time`, garde la durée, le booking_status, et — après 1b — - * préserve les assistants. Un seul écrivain ⇒ plus de conflit « dernier qui - * écrit gagne » entre les deux UIs. - * - * NE PAS importer api/dispatch.js#updateJob pour ces champs. Utiliser ceci. - */ -import { HUB_URL as HUB } from 'src/config/hub' - -async function jpost (path, body) { - const r = await fetch(HUB + path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body || {}), - }) - if (!r.ok) throw new Error('Assignment API ' + r.status + ' ' + path) - return r.json() -} - -// Assigner un job à un tech sur une date (le hub pose first-fit start_time + garde durée + booking_status). -export const assign = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date }) - -// Réordonner / re-prioriser les jobs d'un tech×jour — updates = [{ job, route_order, priority? }] -export const reorder = (updates) => jpost('/roster/reorder-jobs', { updates }) - -// Retirer un job du tech (retour au pool non assigné). -export const unassign = (job) => jpost('/roster/unassign-job', { job }) - -// Équipe (lead + assistants). assistants = [{ tech_id, tech_name, duration_h, note, pinned }]. -// Le LEAD reste `assigned_tech` (inchangé) ; on ne fait qu'éditer la table-enfant `assistants`. -// Route hub /roster/job/team ajoutée en 1b (qui persiste la table-enfant + recalcule l'occupation -// des assistants pinnés). Avant 1b, cet appel échoue proprement (404) — aucune vue ne l'appelle encore. -export const setTeam = (job, assistants) => jpost('/roster/job/team', { job, assistants }) diff --git a/apps/ops/src/features/workforce/composables/useAssignment.js b/apps/ops/src/features/workforce/composables/useAssignment.js deleted file mode 100644 index cea4b79..0000000 --- a/apps/ops/src/features/workforce/composables/useAssignment.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * useAssignment — composable d'assignation UNIFIÉE (le SEUL point d'écriture - * job→tech, partagé par le board Dispatch ET la grille Planification). - * ----------------------------------------------------------------------------- - * Les vues appellent assign/reorder/unassign/setTeam — JAMAIS updateJob() direct. - * Le composable est agnostique du store : la vue fournit ses hooks optimistes. - * - * const A = useAssignment({ - * onError: (e) => $q.notify({ type:'negative', message:String(e) }), - * onLocal: (fn) => fn(), // appliquer une MAJ locale optimiste (selon le store) - * }) - * await A.assign(jobName, techId, '2026-06-10') - * - * `assigned_tech` = clé d'identité tech NORMALISÉE (voir useResources.techKey) : - * on passe l'id tech ; le hub résout le docname Dispatch Technician. - */ -import * as api from '../api/assignment' - -// Sérialise une liste d'assistants (modèle front {techId,...} OU déjà {tech_id,...}) → format hub/ERPNext. -export function serializeAssistants (assistants) { - return (assistants || []).map(a => ({ - tech_id: a.techId ?? a.tech_id, - tech_name: a.techName ?? a.tech_name ?? '', - duration_h: a.duration ?? a.duration_h ?? null, - note: a.note || '', - pinned: a.pinned ? 1 : 0, - })) -} - -const aid = (a) => a && (a.techId ?? a.tech_id) - -export function useAssignment (opts = {}) { - const onErr = opts.onError || ((e) => console.error('[assignment]', e)) - const local = opts.onLocal || ((fn) => { try { fn && fn() } catch (e) { /* noop */ } }) - - async function run (fn, optimistic) { - if (optimistic) local(optimistic) // MAJ optimiste immédiate (UX réactive) - try { return await fn() } catch (e) { onErr(e); throw e } - } - - // Assigner job→tech sur une date (option optimistic = closure de MAJ locale). - const assign = (job, tech, date, optimistic) => run(() => api.assign(job, tech, date), optimistic) - // Réordonner : updates = [{ job, route_order, priority? }]. - const reorder = (updates, optimistic) => run(() => api.reorder(updates), optimistic) - // Désassigner (retour au pool). - const unassign = (job, optimistic) => run(() => api.unassign(job), optimistic) - // Remplacer l'équipe complète d'un job. - const setTeam = (job, assistants, optimistic) => run(() => api.setTeam(job, serializeAssistants(assistants)), optimistic) - - // Ajouter un assistant (dédupe par tech) — `current` = liste actuelle d'assistants du job. - function addAssistant (job, current, assistant, optimistic) { - const next = [...(current || []).filter(a => aid(a) !== aid(assistant)), assistant] - return setTeam(job, next, optimistic) - } - // Retirer un assistant par id tech. - function removeAssistant (job, current, techId, optimistic) { - const next = (current || []).filter(a => aid(a) !== techId) - return setTeam(job, next, optimistic) - } - - return { assign, reorder, unassign, setTeam, addAssistant, removeAssistant } -} diff --git a/apps/ops/src/features/workforce/composables/useResources.js b/apps/ops/src/features/workforce/composables/useResources.js deleted file mode 100644 index a4d4eab..0000000 --- a/apps/ops/src/features/workforce/composables/useResources.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * useResources — « rail » de ressources UNIFIÉE (techs + sous-groupes + recherche - * + filtres + tri + masqués + scoring), partagée par TOUTES les vues. - * ----------------------------------------------------------------------------- - * CONVERGENCE : aujourd'hui la liste de techs / sous-groupes / filtres est - * RECRÉÉE dans chaque page (Dispatch: useResourceFilter ; Planification: - * visibleTechs+groupFilter+search+hiddenTechs+priorityScores). On la définit ICI - * UNE fois ; chaque vue la consomme. Agnostique de la source : on lui passe un - * getter/ref du tableau de techs (modèle « union »). - * - * Modèle Tech union (champs lus ici, tous optionnels sauf id) : - * { id, name, fullName?, group?, status?, resourceType?, tags?[] } - * - * IDENTITÉ : `techKey(t)` normalise sur UNE clé (id) — corrige l'ambiguïté - * docname Dispatch Technician vs technician_id repérée à la cartographie. - */ -import { ref, computed, watch, unref } from 'vue' - -const lsGet = (k, d) => { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v) } catch { return d } } -const lsSet = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)) } catch { /* quota */ } } - -export const techKey = (t) => (t && (t.id ?? t.technician_id ?? t.name)) || '' -const nameOf = (t) => t.fullName || t.full_name || t.name || '' -const lastName = (t) => nameOf(t).split(' ').pop().toLowerCase() - -/** - * @param techsSource ref|getter renvoyant le tableau de techs - * @param opts.lsPrefix préfixe localStorage (persistance par contexte), défaut 'wf-' - * @param opts.defaultType '' | 'human' | 'material' — filtre type de ressource initial - * @param opts.showHidden ref — si true, on affiche les masqués (grisés) au lieu de les cacher - * @param opts.score (t)=>number — score optionnel (Planification : compétence+vitesse+coût) → tri décroissant - * @param opts.loadH (t)=>number — charge horaire (pour le tri 'load') - */ -export function useResources (techsSource, opts = {}) { - const P = opts.lsPrefix || 'wf-' - const techs = () => (unref(typeof techsSource === 'function' ? techsSource() : techsSource)) || [] - - const search = ref('') - const groupFilter = ref(lsGet(P + 'group', '') || '') - const resourceType = ref(opts.defaultType ?? '') // '' | 'human' | 'material' - const hidden = ref(lsGet(P + 'hidden', []) || []) // ids masqués - const sort = ref(lsGet(P + 'sort', 'default') || 'default') // 'default'|'alpha'|'load' - const showHidden = opts.showHidden || ref(false) - - watch(groupFilter, v => lsSet(P + 'group', v)) - watch(hidden, v => lsSet(P + 'hidden', v), { deep: true }) - watch(sort, v => lsSet(P + 'sort', v)) - - // Sous-groupes (tech_group) présents, triés. - const groups = computed(() => { const s = new Set(); for (const t of techs()) if (t.group) s.add(t.group); return [...s].sort() }) - - const isHidden = (t) => hidden.value.includes(techKey(t)) - - // Liste filtrée + triée (humains avant matériel ; puis score|alpha|load|défaut). - const filtered = computed(() => { - let list = techs() - if (resourceType.value) list = list.filter(t => (t.resourceType || 'human') === resourceType.value) - if (search.value) { const q = search.value.toLowerCase(); list = list.filter(t => nameOf(t).toLowerCase().includes(q)) } - if (groupFilter.value) list = list.filter(t => t.group === groupFilter.value) - if (!showHidden.value) list = list.filter(t => !isHidden(t)) - list = [...list].sort((a, b) => { - const at = a.resourceType === 'material' ? 1 : 0, bt = b.resourceType === 'material' ? 1 : 0 - if (at !== bt) return at - bt - if (opts.score) return (opts.score(b) || 0) - (opts.score(a) || 0) - if (sort.value === 'alpha') return lastName(a).localeCompare(lastName(b)) - if (sort.value === 'load' && opts.loadH) return (opts.loadH(a) || 0) - (opts.loadH(b) || 0) - return 0 - }) - return list - }) - - // Regroupé par sous-groupe (groupes nommés d'abord, « Non assigné » à la fin). - const grouped = computed(() => { - const m = new Map() - for (const t of filtered.value) { const g = t.group || ''; if (!m.has(g)) m.set(g, []); m.get(g).push(t) } - return [...m.entries()] - .sort((a, b) => (!a[0] && b[0]) ? 1 : (a[0] && !b[0]) ? -1 : a[0].localeCompare(b[0])) - .map(([name, list]) => ({ name, label: name || 'Non assigné', techs: list })) - }) - - const toggleHidden = (id) => { hidden.value = hidden.value.includes(id) ? hidden.value.filter(x => x !== id) : [...hidden.value, id] } - const hiddenCount = computed(() => hidden.value.length) - const clearFilters = () => { search.value = ''; groupFilter.value = ''; resourceType.value = opts.defaultType ?? '' } - - return { - // état - search, groupFilter, resourceType, hidden, sort, showHidden, - // dérivés - groups, filtered, grouped, hiddenCount, - // actions - toggleHidden, clearFilters, isHidden, techKey, - } -} diff --git a/apps/ops/src/modules/dispatch/components/BottomPanel.vue b/apps/ops/src/modules/dispatch/components/BottomPanel.vue deleted file mode 100644 index 2088b32..0000000 --- a/apps/ops/src/modules/dispatch/components/BottomPanel.vue +++ /dev/null @@ -1,205 +0,0 @@ - - - diff --git a/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue b/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue deleted file mode 100644 index ebb2155..0000000 --- a/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue +++ /dev/null @@ -1,243 +0,0 @@ - - - - - diff --git a/apps/ops/src/modules/dispatch/components/JobEditModal.vue b/apps/ops/src/modules/dispatch/components/JobEditModal.vue deleted file mode 100644 index 7f1585b..0000000 --- a/apps/ops/src/modules/dispatch/components/JobEditModal.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - diff --git a/apps/ops/src/modules/dispatch/components/MonthCalendar.vue b/apps/ops/src/modules/dispatch/components/MonthCalendar.vue deleted file mode 100644 index f423769..0000000 --- a/apps/ops/src/modules/dispatch/components/MonthCalendar.vue +++ /dev/null @@ -1,153 +0,0 @@ - - - diff --git a/apps/ops/src/modules/dispatch/components/OfferPoolPanel.vue b/apps/ops/src/modules/dispatch/components/OfferPoolPanel.vue deleted file mode 100644 index cb7b37f..0000000 --- a/apps/ops/src/modules/dispatch/components/OfferPoolPanel.vue +++ /dev/null @@ -1,204 +0,0 @@ - - - - - diff --git a/apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue b/apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue deleted file mode 100644 index b56010e..0000000 --- a/apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue +++ /dev/null @@ -1,279 +0,0 @@ - - - - - diff --git a/apps/ops/src/modules/dispatch/components/RightPanel.vue b/apps/ops/src/modules/dispatch/components/RightPanel.vue deleted file mode 100644 index 8beda96..0000000 --- a/apps/ops/src/modules/dispatch/components/RightPanel.vue +++ /dev/null @@ -1,181 +0,0 @@ - - - diff --git a/apps/ops/src/modules/dispatch/components/SbContextMenu.vue b/apps/ops/src/modules/dispatch/components/SbContextMenu.vue deleted file mode 100644 index d3ed98b..0000000 --- a/apps/ops/src/modules/dispatch/components/SbContextMenu.vue +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/apps/ops/src/modules/dispatch/components/SbModal.vue b/apps/ops/src/modules/dispatch/components/SbModal.vue deleted file mode 100644 index ebb2e19..0000000 --- a/apps/ops/src/modules/dispatch/components/SbModal.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/apps/ops/src/modules/dispatch/components/TimelineRow.vue b/apps/ops/src/modules/dispatch/components/TimelineRow.vue deleted file mode 100644 index 0f34758..0000000 --- a/apps/ops/src/modules/dispatch/components/TimelineRow.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - diff --git a/apps/ops/src/modules/dispatch/components/WeekCalendar.vue b/apps/ops/src/modules/dispatch/components/WeekCalendar.vue deleted file mode 100644 index f779fc5..0000000 --- a/apps/ops/src/modules/dispatch/components/WeekCalendar.vue +++ /dev/null @@ -1,225 +0,0 @@ - - - diff --git a/apps/ops/src/stores/dispatch.js b/apps/ops/src/stores/dispatch.js deleted file mode 100644 index 1484efa..0000000 --- a/apps/ops/src/stores/dispatch.js +++ /dev/null @@ -1,395 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { fetchTechnicians, loadTechTags, fetchJobsFast, fetchJobFull, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch' -import { TECH_COLORS } from 'src/config/erpnext' -import { serializeAssistants, normalizeStatus, parseWeeklySchedule } from 'src/composables/useHelpers' -import { useGpsTracking } from 'src/composables/useGpsTracking' - -function nextWeekday () { - const d = new Date() - const day = d.getDay() - if (day === 6) d.setDate(d.getDate() + 2) - else if (day === 0) d.setDate(d.getDate() + 1) - return d.toISOString().slice(0, 10) -} - -export const useDispatchStore = defineStore('dispatch', () => { - const technicians = ref([]) - const jobs = ref([]) - const allTags = ref([]) - const loading = ref(false) - const erpStatus = ref('pending') - const jobVersion = ref(0) // Incremented on any job/queue mutation to bust caches - - const { traccarDevices, pollGps, startGpsTracking, stopGpsTracking } = useGpsTracking(technicians) - - function _mapJob (j) { - return { - id: j.ticket_id || j.name, - name: j.name, - subject: j.subject || 'Job sans titre', - address: j.address || 'Adresse inconnue', - coords: [j.longitude || 0, j.latitude || 0], - // Persisted ERPNext links — surfaced as clickable shortcuts in - // the RightPanel so the dispatcher can jump to the client's - // full record (or the Service Location in ERPNext) without - // leaving the dispatch board. The Mapbox marker still uses - // the cached coords above; for source-of-truth verification - // (geocode mismatch) the rep clicks through to /clients/:id. - customer: j.customer || null, - serviceLocation: j.service_location || null, - priority: j.priority || 'low', - duration: j.duration_h || 1, - status: j.status || 'open', - assignedTech: j.assigned_tech || null, - routeOrder: j.route_order || 0, - legDist: j.leg_distance || null, - legDur: j.leg_duration || null, - scheduledDate: j.scheduled_date || null, - endDate: j.end_date || null, - startTime: j.start_time || null, - assistants: (j.assistants || []).map(a => ({ techId: a.tech_id, techName: a.tech_name, duration: a.duration_h || 0, note: a.note || '', pinned: !!a.pinned })), - tags: (j.tags || []).map(t => t.tag), - tagsWithLevel: (j.tags || []).map(t => ({ tag: t.tag, level: t.level || 0, required: t.required || 0 })), - customer: j.customer || null, - serviceLocation: j.service_location || null, - sourceIssue: j.source_issue || null, - dependsOn: j.depends_on || null, - jobType: j.job_type || null, - legacyDept: j.legacy_dept || null, // département osTicket legacy → coloriage par type - legacyTicketId: j.legacy_ticket_id || null, // n° ticket legacy (affiché dans le panneau détail) - legacyActivationUrl: j.legacy_activation_url || null, // lien connect_ministra (activation STB TV) - legacyDetail: j.legacy_detail || null, // description/contenu du ticket legacy (1er message du fil) - parentJob: j.parent_job || null, - stepOrder: j.step_order || 0, - onOpenWebhook: j.on_open_webhook || null, - onCloseWebhook: j.on_close_webhook || null, - salesOrder: j.sales_order || null, - orderSource: j.order_source || 'Manual', - published: j.published === undefined ? true : !!j.published, - continuous: !!j.continuous, // Emergency: span weekends/off-days - // Recurrence fields - isRecurring: !!j.is_recurring, - recurrenceRule: j.recurrence_rule || null, // RRULE string - recurrenceEnd: j.recurrence_end || null, // YYYY-MM-DD or null = indefinite - pausePeriods: j.pause_periods ? (typeof j.pause_periods === 'string' ? JSON.parse(j.pause_periods) : j.pause_periods) : [], - templateId: j.template_id || null, // For materialized instances → parent template - } - } - - function _mapTech (t, idx) { - const status = normalizeStatus(t.status) - return { - id: t.technician_id || t.name, - name: t.name, - fullName: t.full_name || t.name, - status, - active: status !== 'inactive' && status !== 'off', - group: t.tech_group || '', - resourceType: t.resource_type || 'human', // 'human' | 'material' - resourceCategory: t.resource_category || '', // 'Véhicule' | 'Outil' | 'Salle' | etc. - user: t.user || null, - colorIdx: idx % TECH_COLORS.length, - // Default departure point = Gigafibre HQ (1867 chemin de la Rivière, - // Sainte-Clotilde, QC). Used when a tech has no explicit longitude - // /latitude saved in ERPNext, so route optimization has a sensible - // starting point even before the dispatcher sets it. - coords: [t.longitude || -73.6756177, t.latitude || 45.1599145], - gpsCoords: null, - gpsSpeed: 0, - gpsTime: null, - gpsOnline: false, - traccarDeviceId: t.traccar_device_id || null, - phone: t.phone || '', - email: t.email || '', - absenceReason: t.absence_reason || '', - absenceFrom: t.absence_from || null, - absenceUntil: t.absence_until || null, - absenceStartTime: t.absence_start_time || null, - absenceEndTime: t.absence_end_time || null, - weeklySchedule: parseWeeklySchedule(t.weekly_schedule), - queue: [], - tags: (t.tags || []).map(tg => tg.tag), - tagsWithLevel: (t.tags || []).map(tg => ({ tag: tg.tag, level: tg.level || 0 })), - extraShifts: t.extra_shifts ? (typeof t.extra_shifts === 'string' ? JSON.parse(t.extra_shifts) : t.extra_shifts) : [], - } - } - - function _rebuildQueues () { - technicians.value.forEach(tech => { - tech.queue = jobs.value.filter(j => j.assignedTech === tech.id).sort((a, b) => a.routeOrder - b.routeOrder) - tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id)) - }) - } - - async function loadAll (dateRange = null) { - loading.value = true - erpStatus.value = 'pending' - const t0 = performance.now() - try { - // All 3 fetches in parallel — techs, tags, jobs - const [rawTechs, rawTags, rawJobs] = await Promise.all([ - fetchTechnicians(), - fetchTags(), - fetchJobsFast([['status', 'in', ['open', 'assigned', 'in_progress']]]), - ]) - allTags.value = rawTags - technicians.value = rawTechs.map(_mapTech) - jobs.value = rawJobs.map(_mapJob) - _rebuildQueues() - erpStatus.value = 'ok' - - // Background: load tech tags (child tables) without blocking render - loadTechTags(rawTechs.map(t => t.name)).then(fullDocs => { - for (const doc of fullDocs) { - const tech = technicians.value.find(t => t.name === doc.name) - if (tech && doc.tags?.length) { - tech.tags = doc.tags.map(tg => tg.tag) - tech.tagsWithLevel = doc.tags.map(tg => ({ tag: tg.tag, level: tg.level || 0 })) - } - } - // Tech tags loaded in background - }).catch(() => {}) - } catch (e) { - console.error('[dispatch] loadAll failed:', e) - erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error' - } finally { - loading.value = false - } - } - - // Load full doc with child tables for a specific job (on demand) - async function loadJobDetails (jobId) { - const job = jobs.value.find(j => j.id === jobId || j.name === jobId) - if (!job) return - try { - const full = await fetchJobFull(job.name) - const mapped = _mapJob(full) - Object.assign(job, mapped) - _rebuildQueues() - } catch {} - } - - async function loadJobsForTech (techId) { - loading.value = true - try { - const raw = await fetchJobsFast([['assigned_tech', '=', techId]]) - jobs.value = raw.map(_mapJob) - } finally { - loading.value = false - } - } - - async function setJobStatus (jobId, status) { - const job = jobs.value.find(j => j.id === jobId) - if (!job) return - const prevStatus = job.status - job.status = status - await updateJob(job.id, { status }) - // Fire n8n webhooks on status transitions - _fireWebhookIfNeeded(job, prevStatus, status) - } - - function _fireWebhookIfNeeded (job, prevStatus, newStatus) { - const prev = (prevStatus || '').toLowerCase() - const next = (newStatus || '').toLowerCase() - let url = null - if (next === 'assigned' && prev === 'open' && job.onOpenWebhook) { - url = job.onOpenWebhook - } else if (next === 'completed' && job.onCloseWebhook) { - url = job.onCloseWebhook - } - if (url) { - fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - event: next === 'completed' ? 'job_closed' : 'job_opened', - job_name: job.name, job_subject: job.subject, - job_status: newStatus, job_type: job.jobType, - customer: job.customer, timestamp: new Date().toISOString(), - }), - }).catch(err => console.warn('[n8n webhook]', err)) - } - } - - async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) { - jobVersion.value++ - const job = jobs.value.find(j => j.id === jobId) - if (!job) return - technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) }) - const tech = technicians.value.find(t => t.id === techId) - if (tech) { - job.assignedTech = techId - job.routeOrder = routeOrder - job.status = 'assigned' - if (scheduledDate !== undefined) job.scheduledDate = scheduledDate - tech.queue.splice(routeOrder, 0, job) - tech.queue.forEach((q, i) => { q.routeOrder = i }) - } - const payload = { assigned_tech: techId, route_order: routeOrder, status: 'assigned' } - if (scheduledDate !== undefined) payload.scheduled_date = scheduledDate || '' - await updateJob(job.id, payload) - } - - async function unassignJob (jobId) { - jobVersion.value++ - const job = jobs.value.find(j => j.id === jobId) - if (!job) return - technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) }) - job.assignedTech = null - job.status = 'open' - try { await updateJob(job.name || job.id, { assigned_tech: null, status: 'open' }) } catch (_) {} - } - - async function createJob (fields) { - const localId = 'WO-' + Date.now().toString(36).toUpperCase() - const job = _mapJob({ - ticket_id: localId, name: localId, - subject: fields.subject || 'Nouveau travail', - address: fields.address || '', - longitude: fields.longitude || 0, - latitude: fields.latitude || 0, - duration_h: parseFloat(fields.duration_h) || 1, - priority: fields.priority || 'low', - status: fields.assigned_tech ? 'assigned' : 'open', - assigned_tech: fields.assigned_tech || null, - scheduled_date: fields.scheduled_date || (fields.assigned_tech ? nextWeekday() : null), - start_time: fields.start_time || null, - route_order: 0, - published: 0, - }) - jobs.value.push(job) - if (fields.assigned_tech) { - const tech = technicians.value.find(t => t.id === fields.assigned_tech) - if (tech) { job.routeOrder = tech.queue.length; tech.queue.push(job) } - } - try { - const created = await apiCreateJob({ - subject: job.subject, address: job.address, - longitude: job.coords?.[0] || '', latitude: job.coords?.[1] || '', - duration_h: job.duration, priority: job.priority, status: job.status, - assigned_tech: job.assignedTech || '', scheduled_date: job.scheduledDate || '', - start_time: job.startTime || '', - customer: fields.customer || '', service_location: fields.service_location || '', - source_issue: fields.source_issue || '', depends_on: fields.depends_on || '', - job_type: fields.job_type || '', - parent_job: fields.parent_job || '', step_order: fields.step_order || 0, - on_open_webhook: fields.on_open_webhook || '', on_close_webhook: fields.on_close_webhook || '', - }) - if (created?.name) { job.id = created.name; job.name = created.name } - } catch (_) {} - return job - } - - function publishJobsLocal (jobNames) { - for (const name of jobNames) { - const job = jobs.value.find(j => j.id === name || j.name === name) - if (job) job.published = true - } - } - - async function setJobSchedule (jobId, scheduledDate, startTime) { - jobVersion.value++ - const job = jobs.value.find(j => j.id === jobId) - if (!job) return - job.scheduledDate = scheduledDate || null - job.startTime = startTime !== undefined ? startTime : job.startTime - const payload = { scheduled_date: job.scheduledDate || '' } - if (startTime !== undefined) payload.start_time = startTime || '' - try { await updateJob(job.name || job.id, payload) } catch (_) {} - } - - async function updateJobCoords (jobId, lng, lat) { - const job = jobs.value.find(j => j.id === jobId) - if (!job) return - job.coords = [lng, lat] - try { await updateJob(job.name || job.id, { longitude: lng, latitude: lat }) } catch (_) {} - } - - async function addAssistant (jobId, techId) { - const job = jobs.value.find(j => j.id === jobId) - if (!job) return - if (job.assignedTech === techId) return - if (job.assistants.some(a => a.techId === techId)) return - const tech = technicians.value.find(t => t.id === techId) - const entry = { techId, techName: tech?.fullName || techId, duration: job.duration, note: '', pinned: false } - job.assistants = [...job.assistants, entry] - if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id)) - try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }) } catch (_) {} - } - - async function removeAssistant (jobId, techId) { - const job = jobs.value.find(j => j.id === jobId) - if (!job) return - job.assistants = job.assistants.filter(a => a.techId !== techId) - const tech = technicians.value.find(t => t.id === techId) - if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id)) - try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }) } catch (_) {} - } - - async function reorderTechQueue (techId, fromIdx, toIdx) { - const tech = technicians.value.find(t => t.id === techId) - if (!tech) return - const [moved] = tech.queue.splice(fromIdx, 1) - tech.queue.splice(toIdx, 0, moved) - tech.queue.forEach((q, i) => { q.routeOrder = i }) - await Promise.all(tech.queue.map((q, i) => updateJob(q.id, { route_order: i }))) - } - - function smartAssign (jobId, newTechId, dateStr) { - jobVersion.value++ - const job = jobs.value.find(j => j.id === jobId) - if (!job) return - if (job.assistants.some(a => a.techId === newTechId)) { - job.assistants = job.assistants.filter(a => a.techId !== newTechId) - updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) - } - assignJobToTech(jobId, newTechId, technicians.value.find(t => t.id === newTechId)?.queue.length || 0, dateStr) - _rebuildAssistJobs() - } - - function fullUnassign (jobId) { - jobVersion.value++ - const job = jobs.value.find(j => j.id === jobId) - if (!job) return - if (job.assistants.length) { job.assistants = []; updateJob(job.name || job.id, { assistants: [] }).catch(() => {}) } - unassignJob(jobId) - _rebuildAssistJobs() - } - - function _rebuildAssistJobs () { - technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) }) - } - - async function createTechnician (fields) { - const maxNum = technicians.value.reduce((max, t) => { - const m = (t.id || '').match(/TECH-(\d+)/) - return m ? Math.max(max, parseInt(m[1])) : max - }, 0) - fields.technician_id = 'TECH-' + (maxNum + 1) - const doc = await apiCreateTech(fields) - const tech = _mapTech(doc, technicians.value.length) - technicians.value.push(tech) - return tech - } - - async function deleteTechnician (techId) { - const tech = technicians.value.find(t => t.id === techId) - if (!tech) return - await apiDeleteTech(tech.name) - technicians.value = technicians.value.filter(t => t.id !== techId) - } - - const startGpsPolling = startGpsTracking - const stopGpsPolling = stopGpsTracking - - return { - technicians, jobs, allTags, loading, erpStatus, jobVersion, traccarDevices, - loadAll, loadJobsForTech, loadJobDetails, - setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant, - smartAssign, fullUnassign, publishJobsLocal, - pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling, - createTechnician, deleteTechnician, - } -})