diff --git a/src/composables/useAutoDispatch.js b/src/composables/useAutoDispatch.js
new file mode 100644
index 0000000..90c98d0
--- /dev/null
+++ b/src/composables/useAutoDispatch.js
@@ -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 }
+}
diff --git a/src/composables/useDragDrop.js b/src/composables/useDragDrop.js
index 709565a..222a265 100644
--- a/src/composables/useDragDrop.js
+++ b/src/composables/useDragDrop.js
@@ -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 {
diff --git a/src/composables/useHelpers.js b/src/composables/useHelpers.js
index 2008540..b916161 100644
--- a/src/composables/useHelpers.js
+++ b/src/composables/useHelpers.js
@@ -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' }
diff --git a/src/modules/dispatch/components/MapPanel.vue b/src/modules/dispatch/components/MapPanel.vue
new file mode 100644
index 0000000..c59f032
--- /dev/null
+++ b/src/modules/dispatch/components/MapPanel.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+ {}" :style="`width:${panelW}px;min-width:${panelW}px`">
+
+
+ Carte
+
+ 📍 Cliquer sur la carte pour placer {{ geoFixJob.subject }}
+ ✕ Annuler
+
+
+
+ ● {{ store.technicians.find(t=>t.id===selectedTechId)?.fullName }}
+ · Glisser une job sur le trajet
+
+ Cliquer un technicien pour voir son trajet
+ ✕
+
+
+
+
+
+
+
diff --git a/src/pages/DispatchV2Page.vue b/src/pages/DispatchV2Page.vue
index ea6714e..a6ab582 100644
--- a/src/pages/DispatchV2Page.vue
+++ b/src/pages/DispatchV2Page.vue
@@ -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 }
- })
- if (bestTech) smartAssign(job, bestTech.id, assignDay)
- })
- pushUndo({ type: 'autoDistribute', assignments: prevAssignments, prevQueues })
- bottomSelected.value = new Set(); invalidateRoutes()
-}
-
-// ─── Optimize route ───────────────────────────────────────────────────────────
-async function optimizeRoute () {
+// ─── Auto-dispatch composable ────────────────────────────────────────────────
+const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
+ store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs,
+ bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes,
+})
+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)
- }
- }
- } 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()
+ _optimizeRoute(tech)
+}
+
+// ─── 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
⚙ Critères de dispatch automatique ✕
Glissez pour réordonner. Les critères du haut ont plus de poids.
-
+
+
⠿
{{ i + 1 }}
{{ c.label }}
@@ -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; }
diff --git a/src/stores/dispatch.js b/src/stores/dispatch.js
index 480c375..4a38cdc 100644
--- a/src/stores/dispatch.js
+++ b/src/stores/dispatch.js
@@ -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,
}
})