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 @@ + + + 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 }}
@@ -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, } })