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:
louispaulb 2026-03-24 17:25:33 -04:00
parent a5822f7a5b
commit 859f043bb2
6 changed files with 264 additions and 98 deletions

View 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 }
}

View File

@ -1,6 +1,6 @@
// ── Drag & Drop composable: job drag, tech drag, block move, block resize, batch drag ── // ── Drag & Drop composable: job drag, tech drag, block move, block resize, batch drag ──
import { ref } from 'vue' 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' import { updateJob } from 'src/api/dispatch'
export function useDragDrop (deps) { export function useDragDrop (deps) {
@ -205,7 +205,7 @@ export function useDragDrop (deps) {
if (assist) { if (assist) {
assist.duration = newDur assist.duration = newDur
updateJob(job.name || job.id, { 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(() => {}) }).catch(() => {})
} }
} else { } else {

View File

@ -147,6 +147,11 @@ export function prioColor (p) {
} }
// Status icon (minimal, for timeline blocks) // 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) { export function jobStatusIcon (job) {
const st = (job.status || '').toLowerCase() const st = (job.status || '').toLowerCase()
if (st === 'completed') return { svg: ICON.check, cls: 'si-done' } if (st === 'completed') return { svg: ICON.check, cls: 'si-done' }

View 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>

View File

@ -18,7 +18,7 @@ import RightPanel from 'src/modules/dispatch/components/RightPanel.vue'
// Composables // Composables
import { import {
localDateStr, startOfWeek, startOfMonth, timeToH, fmtDur, localDateStr, startOfWeek, startOfMonth, timeToH, fmtDur,
SVC_COLORS, prioLabel, prioClass, SVC_COLORS, prioLabel, prioClass, serializeAssistants,
jobColor as _jobColorBase, ICON, prioColor, jobColor as _jobColorBase, ICON, prioColor,
} from 'src/composables/useHelpers' } from 'src/composables/useHelpers'
import { useScheduler } from 'src/composables/useScheduler' import { useScheduler } from 'src/composables/useScheduler'
@ -27,6 +27,7 @@ import { useMap } from 'src/composables/useMap'
import { useBottomPanel } from 'src/composables/useBottomPanel' import { useBottomPanel } from 'src/composables/useBottomPanel'
import { useDragDrop } from 'src/composables/useDragDrop' import { useDragDrop } from 'src/composables/useDragDrop'
import { useSelection } from 'src/composables/useSelection' import { useSelection } from 'src/composables/useSelection'
import { useAutoDispatch } from 'src/composables/useAutoDispatch'
// Store // Store
const store = useDispatchStore() const store = useDispatchStore()
@ -298,23 +299,11 @@ const unassignDropActive = ref(false)
// Undo composable // Undo composable
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes) const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
// Smart assign & full unassign // Smart assign & full unassign (delegated to store)
function smartAssign (job, newTechId, dateStr) { function smartAssign (job, newTechId, dateStr) { store.smartAssign(job.id, 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)) })
}
function fullUnassign (job) { function fullUnassign (job) {
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] }) 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.fullUnassign(job.id)
store.unassignJob(job.id)
store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) })
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
invalidateRoutes() invalidateRoutes()
} }
@ -415,7 +404,7 @@ function assistCtxTogglePin () {
const assist = job.assistants.find(a => a.techId === techId) const assist = job.assistants.find(a => a.techId === techId)
if (assist) { if (assist) {
assist.pinned = !assist.pinned 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() invalidateRoutes()
} }
assistCtx.value = null assistCtx.value = null
@ -438,7 +427,7 @@ function confirmAssistNote () {
const assist = job.assistants.find(a => a.techId === techId) const assist = job.assistants.find(a => a.techId === techId)
if (assist) { if (assist) {
assist.note = note 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 assistNoteModal.value = null
} }
@ -481,82 +470,27 @@ async function confirmWo () {
woModal.value = null woModal.value = null
} }
// Auto-distribute // Auto-dispatch composable
async function autoDistribute () { const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
const techs = filteredResources.value store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs,
if (!techs.length) return bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes,
const today = todayStr })
let pool function optimizeRoute () {
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) smartAssign(job, bestTech.id, assignDay)
})
pushUndo({ type: 'autoDistribute', assignments: prevAssignments, prevQueues })
bottomSelected.value = new Set(); invalidateRoutes()
}
// Optimize route
async function optimizeRoute () {
if (!techCtx.value) return if (!techCtx.value) return
const tech = techCtx.value.tech; techCtx.value = null const tech = techCtx.value.tech; techCtx.value = null
const dayStr = localDateStr(periodStart.value) _optimizeRoute(tech)
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)) // Criteria drag-and-drop
if (jobsWithCoords.length < 2) return const critDragIdx = ref(null)
const urgent = jobsWithCoords.filter(j => j.priority === 'high'), normal = jobsWithCoords.filter(j => j.priority !== 'high') const critDragOver = ref(null)
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 } function dropCriterion (toIdx) {
const home = (tech.coords?.[0] && tech.coords?.[1]) ? tech.coords : jobsWithCoords[0].coords const fromIdx = critDragIdx.value
const orderedUrgent = nearestNeighbor(home, urgent) if (fromIdx == null || fromIdx === toIdx) { critDragIdx.value = null; critDragOver.value = null; return }
const orderedNormal = nearestNeighbor(orderedUrgent.length ? orderedUrgent.at(-1).coords : home, normal) const arr = dispatchCriteria.value
const reordered = [...orderedUrgent, ...orderedNormal] const [item] = arr.splice(fromIdx, 1)
try { arr.splice(toIdx, 0, item)
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1]) critDragIdx.value = null; critDragOver.value = null
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()
} }
// Board tabs // Board tabs
@ -837,7 +771,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
:is-elevated="techHasLinkedJob(tech)||techIsHovered(tech)" :is-elevated="techHasLinkedJob(tech)||techIsHovered(tech)"
:drop-ghost-x="dropGhost?.techId===tech.id ? dropGhost.x : null" :drop-ghost-x="dropGhost?.techId===tech.id ? dropGhost.x : null"
@select-tech="selectTechOnBoard" @ctx-tech="openTechCtx" @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-dragover="onTimelineDragOver" @timeline-dragleave="onTimelineDragLeave"
@timeline-drop="onTimelineDrop" @timeline-drop="onTimelineDrop"
@job-dragstart="onJobDragStart" @job-click="selectJob" @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-hdr"><span> Critères de dispatch automatique</span><button class="sb-rp-close" @click="dispatchCriteriaModal=false"></button></div>
<div class="sb-modal-body"> <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> <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> <span class="sb-crit-order">{{ i + 1 }}</span>
<label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label> <label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label>
<div class="sb-crit-arrows"> <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 { 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: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-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 ── */ /* ── Month ── */
.sb-month-wrap { flex:1; overflow-y:auto; display:flex; flex-direction:column; padding:0.5rem; gap:0.25rem; } .sb-month-wrap { flex:1; overflow-y:auto; display:flex; flex-direction:column; padding:0.5rem; gap:0.25rem; }

View File

@ -6,6 +6,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags } from 'src/api/dispatch' import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags } from 'src/api/dispatch'
import { TECH_COLORS } from 'src/config/erpnext' import { TECH_COLORS } from 'src/config/erpnext'
import { serializeAssistants } from 'src/composables/useHelpers'
export const useDispatchStore = defineStore('dispatch', () => { export const useDispatchStore = defineStore('dispatch', () => {
const technicians = ref([]) 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)) if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
try { try {
await updateJob(job.name || job.id, { 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 (_) {} } 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)) if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
try { try {
await updateJob(job.name || job.id, { 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 (_) {} } 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 { return {
technicians, jobs, allTags, loading, erpStatus, technicians, jobs, allTags, loading, erpStatus,
loadAll, loadJobsForTech, loadAll, loadJobsForTech,
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant, setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
smartAssign, fullUnassign,
} }
}) })