Refactor: extract autoDispatch, serializeAssistants, store assign logic
- Extract useAutoDispatch.js (autoDistribute + optimizeRoute) - Add serializeAssistants() to useHelpers — removes 6 duplications - Move smartAssign/fullUnassign into Pinia store - Add drag-and-drop on dispatch criteria modal - DispatchV2Page.vue: 1463 → 1385 lines Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a5822f7a5b
commit
859f043bb2
136
src/composables/useAutoDispatch.js
Normal file
136
src/composables/useAutoDispatch.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// ── Auto-dispatch composable: autoDistribute + optimizeRoute ─────────────────
|
||||
import { localDateStr } from './useHelpers'
|
||||
import { updateJob } from 'src/api/dispatch'
|
||||
|
||||
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)
|
||||
}
|
||||
const unassigned = pool.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0))
|
||||
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) 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 <= 12) {
|
||||
const url = `https://api.mapbox.com/optimized-trips/v1/mapbox/driving/${coords.join(';')}?overview=false${hasHome ? '&source=first' : ''}&roundtrip=false&destination=any&access_token=${MAPBOX_TOKEN}`
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
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 }
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// ── Drag & Drop composable: job drag, tech drag, block move, block resize, batch drag ──
|
||||
import { ref } from 'vue'
|
||||
import { snapH, hToTime, fmtDur, localDateStr, SNAP } from './useHelpers'
|
||||
import { snapH, hToTime, fmtDur, localDateStr, SNAP, serializeAssistants } from './useHelpers'
|
||||
import { updateJob } from 'src/api/dispatch'
|
||||
|
||||
export function useDragDrop (deps) {
|
||||
|
|
@ -205,7 +205,7 @@ export function useDragDrop (deps) {
|
|||
if (assist) {
|
||||
assist.duration = newDur
|
||||
updateJob(job.name || job.id, {
|
||||
assistants: job.assistants.map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 })),
|
||||
assistants: serializeAssistants(job.assistants),
|
||||
}).catch(() => {})
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,11 @@ export function prioColor (p) {
|
|||
}
|
||||
|
||||
// Status icon (minimal, for timeline blocks)
|
||||
// Serialize assistants array for ERPNext API calls (used in store + page)
|
||||
export function serializeAssistants (assistants) {
|
||||
return (assistants || []).map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 }))
|
||||
}
|
||||
|
||||
export function jobStatusIcon (job) {
|
||||
const st = (job.status || '').toLowerCase()
|
||||
if (st === 'completed') return { svg: ICON.check, cls: 'si-done' }
|
||||
|
|
|
|||
50
src/modules/dispatch/components/MapPanel.vue
Normal file
50
src/modules/dispatch/components/MapPanel.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { SVC_COLORS } from 'src/composables/useHelpers'
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
panelW: Number,
|
||||
selectedTechId: String,
|
||||
geoFixJob: Object,
|
||||
mapContainer: Object, // template ref
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'close', 'resize-start', 'cancel-geofix',
|
||||
])
|
||||
|
||||
const store = inject('store')
|
||||
const TECH_COLORS = inject('TECH_COLORS')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="visible">
|
||||
<div class="sb-map-backdrop" @click="emit('close')"></div>
|
||||
<div class="sb-map-panel" @click.stop="()=>{}" :style="`width:${panelW}px;min-width:${panelW}px`">
|
||||
<div class="sb-map-resize-handle" @mousedown.prevent="emit('resize-start', $event)"></div>
|
||||
<div class="sb-map-bar" :class="{ 'sb-map-bar-geofix': geoFixJob }">
|
||||
<span class="sb-map-title">Carte</span>
|
||||
<template v-if="geoFixJob">
|
||||
<span class="sb-geofix-hint">📍 Cliquer sur la carte pour placer <strong>{{ geoFixJob.subject }}</strong></span>
|
||||
<button class="sb-geofix-cancel" @click="emit('cancel-geofix')">✕ Annuler</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="selectedTechId" class="sb-map-tech"
|
||||
:style="'color:'+TECH_COLORS[store.technicians.find(t=>t.id===selectedTechId)?.colorIdx||0]">
|
||||
● {{ store.technicians.find(t=>t.id===selectedTechId)?.fullName }}
|
||||
<span class="sb-map-route-hint">· Glisser une job sur le trajet</span>
|
||||
</span>
|
||||
<span v-else class="sb-map-hint">Cliquer un technicien pour voir son trajet</span>
|
||||
<button class="sb-map-close" @click="emit('close')">✕</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="sb-map-legend">
|
||||
<div v-for="(col, lbl) in SVC_COLORS" :key="lbl" class="sb-legend-item">
|
||||
<span class="sb-legend-dot" :style="'background:'+col"></span>{{ lbl }}
|
||||
</div>
|
||||
</div>
|
||||
<div ref="mapContainer" class="sb-map"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -18,7 +18,7 @@ import RightPanel from 'src/modules/dispatch/components/RightPanel.vue'
|
|||
// ── Composables ─────────────────────────────────────────────────────────────────
|
||||
import {
|
||||
localDateStr, startOfWeek, startOfMonth, timeToH, fmtDur,
|
||||
SVC_COLORS, prioLabel, prioClass,
|
||||
SVC_COLORS, prioLabel, prioClass, serializeAssistants,
|
||||
jobColor as _jobColorBase, ICON, prioColor,
|
||||
} from 'src/composables/useHelpers'
|
||||
import { useScheduler } from 'src/composables/useScheduler'
|
||||
|
|
@ -27,6 +27,7 @@ import { useMap } from 'src/composables/useMap'
|
|||
import { useBottomPanel } from 'src/composables/useBottomPanel'
|
||||
import { useDragDrop } from 'src/composables/useDragDrop'
|
||||
import { useSelection } from 'src/composables/useSelection'
|
||||
import { useAutoDispatch } from 'src/composables/useAutoDispatch'
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
const store = useDispatchStore()
|
||||
|
|
@ -298,23 +299,11 @@ const unassignDropActive = ref(false)
|
|||
// ─── Undo composable ─────────────────────────────────────────────────────────
|
||||
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
|
||||
|
||||
// ─── Smart assign & full unassign ─────────────────────────────────────────────
|
||||
function smartAssign (job, newTechId, dateStr) {
|
||||
if (job.assistants.some(a => a.techId === newTechId)) {
|
||||
job.assistants = job.assistants.filter(a => a.techId !== newTechId)
|
||||
updateJob(job.name || job.id, {
|
||||
assistants: job.assistants.map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 })),
|
||||
}).catch(() => {})
|
||||
}
|
||||
store.assignJobToTech(job.id, newTechId, store.technicians.find(t => t.id === newTechId)?.queue.length || 0, dateStr)
|
||||
store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) })
|
||||
}
|
||||
|
||||
// ─── Smart assign & full unassign (delegated to store) ───────────────────────
|
||||
function smartAssign (job, newTechId, dateStr) { store.smartAssign(job.id, newTechId, dateStr) }
|
||||
function fullUnassign (job) {
|
||||
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] })
|
||||
if (job.assistants.length) { job.assistants = []; updateJob(job.name || job.id, { assistants: [] }).catch(() => {}) }
|
||||
store.unassignJob(job.id)
|
||||
store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) })
|
||||
store.fullUnassign(job.id)
|
||||
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
|
||||
invalidateRoutes()
|
||||
}
|
||||
|
|
@ -415,7 +404,7 @@ function assistCtxTogglePin () {
|
|||
const assist = job.assistants.find(a => a.techId === techId)
|
||||
if (assist) {
|
||||
assist.pinned = !assist.pinned
|
||||
updateJob(job.name || job.id, { assistants: job.assistants.map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 })) }).catch(() => {})
|
||||
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
|
||||
invalidateRoutes()
|
||||
}
|
||||
assistCtx.value = null
|
||||
|
|
@ -438,7 +427,7 @@ function confirmAssistNote () {
|
|||
const assist = job.assistants.find(a => a.techId === techId)
|
||||
if (assist) {
|
||||
assist.note = note
|
||||
updateJob(job.name || job.id, { assistants: job.assistants.map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 })) }).catch(() => {})
|
||||
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
|
||||
}
|
||||
assistNoteModal.value = null
|
||||
}
|
||||
|
|
@ -481,82 +470,27 @@ async function confirmWo () {
|
|||
woModal.value = null
|
||||
}
|
||||
|
||||
// ─── Auto-distribute ─────────────────────────────────────────────────────────
|
||||
async function autoDistribute () {
|
||||
const techs = filteredResources.value
|
||||
if (!techs.length) return
|
||||
const today = todayStr
|
||||
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)
|
||||
}
|
||||
const unassigned = pool.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0))
|
||||
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) 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 }
|
||||
// ─── Auto-dispatch composable ────────────────────────────────────────────────
|
||||
const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
|
||||
store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs,
|
||||
bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes,
|
||||
})
|
||||
if (bestTech) smartAssign(job, bestTech.id, assignDay)
|
||||
})
|
||||
pushUndo({ type: 'autoDistribute', assignments: prevAssignments, prevQueues })
|
||||
bottomSelected.value = new Set(); invalidateRoutes()
|
||||
}
|
||||
|
||||
// ─── Optimize route ───────────────────────────────────────────────────────────
|
||||
async function optimizeRoute () {
|
||||
function optimizeRoute () {
|
||||
if (!techCtx.value) return
|
||||
const tech = techCtx.value.tech; techCtx.value = null
|
||||
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'), 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 } }); const p = remaining.splice(bi, 1)[0]; result.push(p); cur = p.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 <= 12) {
|
||||
const url = `https://api.mapbox.com/optimized-trips/v1/mapbox/driving/${coords.join(';')}?overview=false${hasHome ? '&source=first' : ''}&roundtrip=false&destination=any&access_token=${MAPBOX_TOKEN}`
|
||||
const res = await fetch(url); const data = await res.json()
|
||||
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)
|
||||
_optimizeRoute(tech)
|
||||
}
|
||||
}
|
||||
} 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()
|
||||
|
||||
// ─── Criteria drag-and-drop ───────────────────────────────────────────────────
|
||||
const critDragIdx = ref(null)
|
||||
const critDragOver = ref(null)
|
||||
function dropCriterion (toIdx) {
|
||||
const fromIdx = critDragIdx.value
|
||||
if (fromIdx == null || fromIdx === toIdx) { critDragIdx.value = null; critDragOver.value = null; return }
|
||||
const arr = dispatchCriteria.value
|
||||
const [item] = arr.splice(fromIdx, 1)
|
||||
arr.splice(toIdx, 0, item)
|
||||
critDragIdx.value = null; critDragOver.value = null
|
||||
}
|
||||
|
||||
// ─── Board tabs ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -837,7 +771,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
|||
:is-elevated="techHasLinkedJob(tech)||techIsHovered(tech)"
|
||||
:drop-ghost-x="dropGhost?.techId===tech.id ? dropGhost.x : null"
|
||||
@select-tech="selectTechOnBoard" @ctx-tech="openTechCtx"
|
||||
@drag-tech-start="onTechDragStart" @reorder-drop="onTechReorderDrop"
|
||||
@drag-tech-start="(e, tech) => { onTechReorderStart(e, tech); onTechDragStart(e, tech) }" @reorder-drop="onTechReorderDrop"
|
||||
@timeline-dragover="onTimelineDragOver" @timeline-dragleave="onTimelineDragLeave"
|
||||
@timeline-drop="onTimelineDrop"
|
||||
@job-dragstart="onJobDragStart" @job-click="selectJob"
|
||||
|
|
@ -1032,7 +966,15 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
|||
<div class="sb-modal-hdr"><span>⚙ Critères de dispatch automatique</span><button class="sb-rp-close" @click="dispatchCriteriaModal=false">✕</button></div>
|
||||
<div class="sb-modal-body">
|
||||
<p style="font-size:0.65rem;color:var(--sb-muted);margin:0 0 0.5rem">Glissez pour réordonner. Les critères du haut ont plus de poids.</p>
|
||||
<div v-for="(c, i) in dispatchCriteria" :key="c.id" class="sb-crit-row">
|
||||
<div v-for="(c, i) in dispatchCriteria" :key="c.id" class="sb-crit-row"
|
||||
draggable="true"
|
||||
:class="{ 'sb-crit-drag-over': critDragOver === i }"
|
||||
@dragstart="critDragIdx = i; $event.dataTransfer.effectAllowed = 'move'"
|
||||
@dragend="critDragIdx = null; critDragOver = null"
|
||||
@dragover.prevent="critDragOver = i"
|
||||
@dragleave="critDragOver === i && (critDragOver = null)"
|
||||
@drop.prevent="dropCriterion(i)">
|
||||
<span class="sb-crit-handle" title="Glisser">⠿</span>
|
||||
<span class="sb-crit-order">{{ i + 1 }}</span>
|
||||
<label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label>
|
||||
<div class="sb-crit-arrows">
|
||||
|
|
@ -1320,6 +1262,11 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; }
|
|||
.sb-crit-arrows button { background:none; border:1px solid var(--sb-border); border-radius:3px; color:var(--sb-muted); font-size:0.5rem; padding:0 4px; cursor:pointer; line-height:1.2; }
|
||||
.sb-crit-arrows button:hover:not(:disabled) { color:var(--sb-text); border-color:var(--sb-border-acc); }
|
||||
.sb-crit-arrows button:disabled { opacity:0.25; cursor:default; }
|
||||
.sb-crit-row { cursor:grab; transition:background 0.12s, transform 0.12s, border-color 0.12s; }
|
||||
.sb-crit-row:active { cursor:grabbing; }
|
||||
.sb-crit-drag-over { border-color:var(--sb-acc); background:rgba(99,102,241,0.12); transform:scale(1.02); }
|
||||
.sb-crit-handle { color:var(--sb-muted); font-size:0.7rem; cursor:grab; user-select:none; flex-shrink:0; opacity:0.4; transition:opacity 0.12s; }
|
||||
.sb-crit-row:hover .sb-crit-handle { opacity:0.8; }
|
||||
|
||||
/* ── Month ── */
|
||||
.sb-month-wrap { flex:1; overflow-y:auto; display:flex; flex-direction:column; padding:0.5rem; gap:0.25rem; }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { defineStore } from 'pinia'
|
|||
import { ref } from 'vue'
|
||||
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags } from 'src/api/dispatch'
|
||||
import { TECH_COLORS } from 'src/config/erpnext'
|
||||
import { serializeAssistants } from 'src/composables/useHelpers'
|
||||
|
||||
export const useDispatchStore = defineStore('dispatch', () => {
|
||||
const technicians = ref([])
|
||||
|
|
@ -207,7 +208,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||
try {
|
||||
await updateJob(job.name || job.id, {
|
||||
assistants: job.assistants.map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 })),
|
||||
assistants: serializeAssistants(job.assistants),
|
||||
})
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -220,7 +221,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||
try {
|
||||
await updateJob(job.name || job.id, {
|
||||
assistants: job.assistants.map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 })),
|
||||
assistants: serializeAssistants(job.assistants),
|
||||
})
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -237,9 +238,36 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
)
|
||||
}
|
||||
|
||||
// ── Smart assign (removes circular assistant deps) ──────────────────────
|
||||
function smartAssign (jobId, newTechId, dateStr) {
|
||||
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()
|
||||
}
|
||||
|
||||
// ── Full unassign (clears assistants + unassigns) ──────────────────────
|
||||
function fullUnassign (jobId) {
|
||||
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()
|
||||
}
|
||||
|
||||
// Rebuild all tech.assistJobs references
|
||||
function _rebuildAssistJobs () {
|
||||
technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) })
|
||||
}
|
||||
|
||||
return {
|
||||
technicians, jobs, allTags, loading, erpStatus,
|
||||
loadAll, loadJobsForTech,
|
||||
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
|
||||
smartAssign, fullUnassign,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user