diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..62b26e1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.git +*.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index bb19f6d..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,161 +0,0 @@ -# OSS/BSS Field Dispatch — Architecture - -## Overview - -Field service dispatch & scheduling PWA integrated with ERPNext (Frappe) on PostgreSQL. -Manages technicians, work orders, route optimization, and team scheduling. - -## Stack - -| Layer | Technology | Notes | -|-------|-----------|-------| -| **Frontend** | Vue 3 + Quasar (PWA) | ` + + diff --git a/src/modules/dispatch/components/RightPanel.vue b/src/modules/dispatch/components/RightPanel.vue new file mode 100644 index 0000000..8427113 --- /dev/null +++ b/src/modules/dispatch/components/RightPanel.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/modules/dispatch/components/WeekCalendar.vue b/src/modules/dispatch/components/WeekCalendar.vue new file mode 100644 index 0000000..0bb3bb6 --- /dev/null +++ b/src/modules/dispatch/components/WeekCalendar.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/pages/DispatchV2Page.vue b/src/pages/DispatchV2Page.vue index 14c07ea..ea6714e 100644 --- a/src/pages/DispatchV2Page.vue +++ b/src/pages/DispatchV2Page.vue @@ -2,38 +2,43 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue' import { useDispatchStore } from 'src/stores/dispatch' import { TECH_COLORS, MAPBOX_TOKEN } from 'src/config/erpnext' -import { fetchOpenRequests, updateServiceRequestStatus } from 'src/api/service-request' +import { fetchOpenRequests } from 'src/api/service-request' import { updateJob, updateTech, createTag as apiCreateTag } from 'src/api/dispatch' + +// ── Components ────────────────────────────────────────────────────────────────── import TagInput from 'src/components/TagInput.vue' import TimelineRow from 'src/modules/dispatch/components/TimelineRow.vue' import BottomPanel from 'src/modules/dispatch/components/BottomPanel.vue' import JobEditModal from 'src/modules/dispatch/components/JobEditModal.vue' import WoCreateModal from 'src/modules/dispatch/components/WoCreateModal.vue' +import WeekCalendar from 'src/modules/dispatch/components/WeekCalendar.vue' +import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue' +import RightPanel from 'src/modules/dispatch/components/RightPanel.vue' + +// ── Composables ───────────────────────────────────────────────────────────────── import { - localDateStr, startOfWeek, startOfMonth, timeToH, hToTime, fmtDur, - SNAP_MIN, SNAP, snapH, dayLoadColor, shortAddr, - SVC_COLORS, SVC_ICONS, jobSvcCode, jobSpansDate, sortJobsByTime, - stOf, prioLabel, prioClass, jobStatusIcon, - jobColor as _jobColorBase, ICON, jobTypeIcon, prioColor, + localDateStr, startOfWeek, startOfMonth, timeToH, fmtDur, + SVC_COLORS, prioLabel, prioClass, + jobColor as _jobColorBase, ICON, prioColor, } from 'src/composables/useHelpers' import { useScheduler } from 'src/composables/useScheduler' import { useUndo } from 'src/composables/useUndo' import { useMap } from 'src/composables/useMap' import { useBottomPanel } from 'src/composables/useBottomPanel' +import { useDragDrop } from 'src/composables/useDragDrop' +import { useSelection } from 'src/composables/useSelection' // ─── Store ──────────────────────────────────────────────────────────────────── const store = useDispatchStore() // ─── Date / View ───────────────────────────────────────────────────────────── -const currentView = ref(localStorage.getItem('sbv2-view') || 'week') // 'day'|'week'|'month' +const currentView = ref(localStorage.getItem('sbv2-view') || 'week') const savedDate = localStorage.getItem('sbv2-date') const anchorDate = ref(savedDate ? new Date(savedDate + 'T00:00:00') : new Date()) watch(currentView, v => localStorage.setItem('sbv2-view', v)) watch(anchorDate, d => localStorage.setItem('sbv2-date', localDateStr(d))) -// localDateStr, startOfWeek, startOfMonth imported from useHelpers - const periodStart = computed(() => { const d = new Date(anchorDate.value); d.setHours(0,0,0,0) if (currentView.value === 'day') return d @@ -64,7 +69,6 @@ const periodLabel = computed(() => { return s.toLocaleDateString('fr-CA', { month:'long', year:'numeric' }) }) const todayStr = localDateStr(new Date()) -function isDayToday (d) { return localDateStr(d) === todayStr } function prevPeriod () { const d = new Date(anchorDate.value) @@ -83,8 +87,6 @@ function nextPeriod () { function goToToday () { anchorDate.value = new Date(); currentView.value = 'day' } function goToDay (d) { anchorDate.value = new Date(d); currentView.value = 'day' } -// shortAddr imported from useHelpers - // ─── Tags ──────────────────────────────────────────────────────────────────── const techTagModal = ref(null) function getTagColor (tagLabel) { @@ -92,15 +94,12 @@ function getTagColor (tagLabel) { return t?.color || '#6b7280' } async function onCreateTag (label) { - // Create tag in ERPNext and add to store try { const created = await apiCreateTag(label, 'Custom', '#6b7280') if (created) store.allTags.push({ name: created.name, label: created.label, color: created.color || '#6b7280', category: created.category || 'Custom' }) } catch (e) { - // Tag may already exist — add to local list anyway - if (!store.allTags.some(t => t.label === label)) { + if (!store.allTags.some(t => t.label === label)) store.allTags.push({ name: label, label, color: '#6b7280', category: 'Custom' }) - } } } function persistJobTags (job) { @@ -137,17 +136,16 @@ function selectAddr (addr, target) { addrResults.value = [] } - function setEndDate (job, endDate) { job.endDate = endDate || null updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {}) } // ─── Layout state ───────────────────────────────────────────────────────────── -const sidebarCollapsed = ref(localStorage.getItem('sbv2-sideCollapsed') !== 'false') // collapsed by default -const sidebarFlyout = ref(null) // 'filter'|'unassigned'|'projects'|null +const sidebarCollapsed = ref(localStorage.getItem('sbv2-sideCollapsed') !== 'false') +const sidebarFlyout = ref(null) const mapVisible = ref(localStorage.getItem('sbv2-map') === 'true') -const rightPanel = ref(null) // { mode: 'details', data: { job, tech } } +const rightPanel = ref(null) const searchQuery = ref('') watch(sidebarCollapsed, v => localStorage.setItem('sbv2-sideCollapsed', v ? 'true' : 'false')) @@ -157,26 +155,16 @@ watch(mapVisible, v => localStorage.setItem('sbv2-map', v ? 'true' : 'false')) const editModal = ref(null) function openEditModal (job) { editModal.value = { - job, - subject: job.subject || '', - address: job.address || '', - note: job.note || '', - duration: job.duration || 1, - priority: job.priority || 'low', - tags: [...(job.tags || [])], - latitude: job.latitude || null, - longitude: job.longitude || null, + job, subject: job.subject || '', address: job.address || '', + note: job.note || '', duration: job.duration || 1, + priority: job.priority || 'low', tags: [...(job.tags || [])], + latitude: job.latitude || null, longitude: job.longitude || null, } } function confirmEdit () { if (!editModal.value) return const { job, subject, address, note, duration, priority, tags, latitude, longitude } = editModal.value - job.subject = subject - job.address = address - job.note = note - job.duration = parseFloat(duration) || 1 - job.priority = priority - job.tags = [...tags] + Object.assign(job, { subject, address, note, duration: parseFloat(duration) || 1, priority, tags: [...tags] }) if (latitude) job.latitude = latitude if (longitude) job.longitude = longitude updateJob(job.name || job.id, { @@ -198,36 +186,25 @@ watch(filterStatus, v => localStorage.setItem('sbv2-filterStatus', v)) const resSelectorOpen = ref(false) const tempSelectedIds = ref([]) -const techSort = ref(localStorage.getItem('sbv2-techSort') || 'default') // 'default'|'alpha'|'manual' -const manualOrder = ref(JSON.parse(localStorage.getItem('sbv2-techOrder') || '[]')) +const techSort = ref(localStorage.getItem('sbv2-techSort') || 'default') +const manualOrder = ref(JSON.parse(localStorage.getItem('sbv2-techOrder') || '[]')) watch(techSort, v => localStorage.setItem('sbv2-techSort', v)) const filteredResources = computed(() => { let list = store.technicians - if (searchQuery.value) { - const q = searchQuery.value.toLowerCase() - list = list.filter(t => t.fullName.toLowerCase().includes(q)) - } + if (searchQuery.value) { const q = searchQuery.value.toLowerCase(); list = list.filter(t => t.fullName.toLowerCase().includes(q)) } if (filterStatus.value) list = list.filter(t => (t.status||'available') === filterStatus.value) if (selectedResIds.value.length) list = list.filter(t => selectedResIds.value.includes(t.id)) if (filterTags.value.length) list = list.filter(t => filterTags.value.every(ft => (t.tags||[]).includes(ft))) if (techSort.value === 'alpha') { - list = [...list].sort((a, b) => { - const aLast = a.fullName.split(' ').pop().toLowerCase() - const bLast = b.fullName.split(' ').pop().toLowerCase() - return aLast.localeCompare(bLast) - }) + list = [...list].sort((a, b) => a.fullName.split(' ').pop().toLowerCase().localeCompare(b.fullName.split(' ').pop().toLowerCase())) } else if (techSort.value === 'manual' && manualOrder.value.length) { const order = manualOrder.value - list = [...list].sort((a, b) => { - const ai = order.indexOf(a.id); const bi = order.indexOf(b.id) - return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi) - }) + list = [...list].sort((a, b) => (order.indexOf(a.id) === -1 ? 999 : order.indexOf(a.id)) - (order.indexOf(b.id) === -1 ? 999 : order.indexOf(b.id))) } return list }) -// Drag-reorder techs const dragReorderTech = ref(null) function onTechReorderStart (e, tech) { dragReorderTech.value = tech @@ -239,12 +216,9 @@ function onTechReorderDrop (e, targetTech) { if (!dragReorderTech.value || dragReorderTech.value.id === targetTech.id) { dragReorderTech.value = null; return } techSort.value = 'manual' const ids = filteredResources.value.map(t => t.id) - const fromIdx = ids.indexOf(dragReorderTech.value.id) - const toIdx = ids.indexOf(targetTech.id) - ids.splice(fromIdx, 1) - ids.splice(toIdx, 0, dragReorderTech.value.id) - manualOrder.value = ids - localStorage.setItem('sbv2-techOrder', JSON.stringify(ids)) + const fromIdx = ids.indexOf(dragReorderTech.value.id), toIdx = ids.indexOf(targetTech.id) + ids.splice(fromIdx, 1); ids.splice(toIdx, 0, dragReorderTech.value.id) + manualOrder.value = ids; localStorage.setItem('sbv2-techOrder', JSON.stringify(ids)) dragReorderTech.value = null } @@ -252,43 +226,27 @@ function openResSelector () { tempSelectedIds.value = [...selectedResIds.value]; function applyResSelector () { selectedResIds.value = [...tempSelectedIds.value]; resSelectorOpen.value = false } function toggleTempRes (id) { const idx = tempSelectedIds.value.indexOf(id) - if (idx >= 0) tempSelectedIds.value.splice(idx, 1) - else tempSelectedIds.value.push(id) + if (idx >= 0) tempSelectedIds.value.splice(idx, 1); else tempSelectedIds.value.push(id) } function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; searchQuery.value = ''; filterTags.value = []; localStorage.removeItem('sbv2-filterTags') } -// ─── Job schedule helpers — backed by store (ERPNext) ───────────────────────── -function getJobDate (jobId) { - return store.jobs.find(j => j.id === jobId)?.scheduledDate || null -} -function getJobTime (jobId) { - return store.jobs.find(j => j.id === jobId)?.startTime || null -} +// ─── Job schedule helpers ───────────────────────────────────────────────────── +function getJobDate (jobId) { return store.jobs.find(j => j.id === jobId)?.scheduledDate || null } +function getJobTime (jobId) { return store.jobs.find(j => j.id === jobId)?.startTime || null } function setJobTime (jobId, time) { const job = store.jobs.find(j => j.id === jobId) if (!job) return store.setJobSchedule(jobId, job.scheduledDate, time || null) job.startHour = time ? timeToH(time) : null } -// timeToH, hToTime, fmtDur, dayLoadColor, sortJobsByTime imported from useHelpers - -// Time-pin modal -const timeModal = ref(null) // { job, techId, time } +const timeModal = ref(null) function openTimeModal (job, techId) { const cur = getJobTime(job.id) timeModal.value = { job, techId, time: cur || '08:00', hasPin: !!cur } } -function confirmTime () { - if (!timeModal.value) return - setJobTime(timeModal.value.job.id, timeModal.value.time) - timeModal.value = null -} -function clearTime () { - if (!timeModal.value) return - setJobTime(timeModal.value.job.id, null) - timeModal.value = null -} +function confirmTime () { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, timeModal.value.time); timeModal.value = null } +function clearTime () { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, null); timeModal.value = null } // ─── Unscheduled / Pending ──────────────────────────────────────────────────── const pendingReqs = ref([]) @@ -301,34 +259,25 @@ async function loadPendingReqs () { const unscheduledJobs = computed(() => store.jobs.filter(j => !j.assignedTech)) const teamJobs = computed(() => store.jobs.filter(j => j.assistants?.length > 0)) -// ─── Bottom panel composable ────────────────────────────────────────────────── -// (extracted to useBottomPanel.js — wired after pushUndo/smartAssign/invalidateRoutes are defined) - -// ─── Service colors & labels ────────────────────────────────────────────────── -// SVC_COLORS, SVC_ICONS, jobSvcCode imported from useHelpers -// jobColor wrapper (needs store access) +// ─── Job color ──────────────────────────────────────────────────────────────── function jobColor (job) { return _jobColorBase(job, TECH_COLORS, store) } // ─── Timeline geometry ──────────────────────────────────────────────────────── -const PX_PER_HR = ref(80) -const ROW_H = 68 - +const PX_PER_HR = ref(80) +const ROW_H = 68 const pxPerHr = computed(() => { if (currentView.value === 'week') return PX_PER_HR.value * 0.55 if (currentView.value === 'month') return 0 return PX_PER_HR.value }) -const dayW = computed(() => { - if (currentView.value === 'month') return 110 - return (H_END - H_START) * pxPerHr.value -}) +const dayW = computed(() => currentView.value === 'month' ? 110 : (H_END - H_START) * pxPerHr.value) const totalW = computed(() => dayW.value * periodDays.value) // ─── Scheduler composable ──────────────────────────────────────────────────── const { H_START, H_END, routeLegs, routeGeometry, techAllJobsForDate, techDayJobsWithTravel, - techBookingsByDay, periodLoadH, techsActiveOnDay, dayJobCount, + periodLoadH, } = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor) const hourTicks = computed(() => { @@ -343,440 +292,14 @@ const hourTicks = computed(() => { return ticks }) -function blockStyle (job, techId) { - const dateStr = getJobDate(job.id) - if (!dateStr) return null - const jd = new Date(dateStr + 'T00:00:00') - const ps = new Date(periodStart.value); ps.setHours(0,0,0,0) - const pe = new Date(ps); pe.setDate(pe.getDate() + periodDays.value) - if (jd < ps || jd >= pe) return null - const di = Math.round((jd - ps) / 86400000) - const dur = parseFloat(job.duration) || 1 - const startH = job.startHour ?? 8 - if (currentView.value === 'month') { - return { left: (di * 110 + 2) + 'px', width: Math.max(40, dur * 16) + 'px', top:'6px', bottom:'6px', position:'absolute' } - } - const left = di * dayW.value + (startH - H_START) * pxPerHr.value - const width = Math.max(18, dur * 60 * (pxPerHr.value / 60)) - return { left: left+'px', width: width+'px', top:'6px', bottom:'6px', position:'absolute' } -} +const isCalView = computed(() => currentView.value === 'week') +const unassignDropActive = ref(false) -function techBookings (tech) { - return tech.queue.map(job => ({ job, style: blockStyle(job, tech.id) })).filter(b => b.style) -} +// ─── Undo composable ───────────────────────────────────────────────────────── +const { pushUndo, performUndo } = useUndo(store, invalidateRoutes) -// techDayJobsWithTravel, routeLegs, routeGeometry, techAllJobsForDate from useScheduler - -// computeDayRoute, drawMapMarkers, drawSelectedRoute — provided by useMap composable (below) -// Forward-declared as let so invalidateRoutes/watchers can reference them before composable init -let computeDayRoute = () => {}, drawMapMarkers = () => {}, drawSelectedRoute = () => {}, getMap = () => null - -// Invalidate cached routes and recompute (called after job assign/move/unassign) -function invalidateRoutes () { - routeLegs.value = {} - routeGeometry.value = {} - if (currentView.value === 'day') { - const ds = localDateStr(periodStart.value) - filteredResources.value.forEach(tech => computeDayRoute(tech, ds)) - } - nextTick(() => { if (getMap()) { drawMapMarkers(); drawSelectedRoute() } }) -} - -// Trigger route fetches whenever day view is shown or date changes -watch( - [currentView, () => anchorDate.value.getTime(), filteredResources], - () => { - if (currentView.value !== 'day') return - // Clear old routes so stale data doesn't linger while new routes load - routeLegs.value = {} - routeGeometry.value = {} - const ds = localDateStr(periodStart.value) - filteredResources.value.forEach(tech => computeDayRoute(tech, ds)) - if (getMap()) { drawMapMarkers(); drawSelectedRoute() } - }, -) - -// Calendar view: group jobs by day column -// jobSpansDate imported from useHelpers - -// techBookingsByDay, periodLoadH from useScheduler -const isCalView = computed(() => currentView.value === 'week') - -// Month view helpers -const monthWeeks = computed(() => { - if (currentView.value !== 'month') return [] - const first = new Date(anchorDate.value.getFullYear(), anchorDate.value.getMonth(), 1) - const last = new Date(anchorDate.value.getFullYear(), anchorDate.value.getMonth() + 1, 0) - const start = startOfWeek(first) - const end = new Date(last) - const dow = end.getDay() - if (dow !== 0) end.setDate(end.getDate() + (7 - dow)) - const weeks = []; let cur = new Date(start) - while (cur <= end) { - const week = [] - for (let i = 0; i < 7; i++) { week.push(new Date(cur)); cur.setDate(cur.getDate() + 1) } - weeks.push(week) - } - return weeks -}) -// techsActiveOnDay, dayJobCount from useScheduler (need filteredResources wrapper) -function techsActiveOnDayW (dateStr) { return techsActiveOnDay(dateStr, filteredResources.value) } -function dayJobCountW (dateStr) { return dayJobCount(dateStr, filteredResources.value) } - -// ─── Drag & Drop ───────────────────────────────────────────────────────────── -const dragJob = ref(null) -const dragSrc = ref(null) // source techId or null -const dragIsAssist = ref(false) // true when dragging an assist block -const dropGhost = ref(null) // { techId, x, dateStr } - -function cleanupDropIndicators () { - document.querySelectorAll('.sb-block-drop-hover').forEach(el => el.classList.remove('sb-block-drop-hover')) - dropGhost.value = null -} -const dragBatchIds = ref(null) // Set of job IDs when multi-dragging from bottom panel -function onJobDragStart (e, job, srcTechId, isAssist = false) { - dragJob.value = job; dragSrc.value = srcTechId || null; dragIsAssist.value = isAssist - // Multi-drag: if this job is part of a bottom-panel selection, carry all selected - if (!srcTechId && bottomSelected.value.size > 1 && bottomSelected.value.has(job.id)) { - dragBatchIds.value = new Set(bottomSelected.value) - e.dataTransfer.setData('text/plain', `batch:${dragBatchIds.value.size}`) - } else { - dragBatchIds.value = null - } - e.dataTransfer.effectAllowed = 'move' - e.target.addEventListener('dragend', () => { cleanupDropIndicators(); dragIsAssist.value = false; dragBatchIds.value = null }, { once: true }) -} -function onTimelineDragOver (e, tech) { - e.preventDefault() - if (!dragJob.value && !dragTech.value) return - const x = e.clientX - e.currentTarget.getBoundingClientRect().left - dropGhost.value = { techId: tech.id, x, dateStr: xToDateStr(x) } -} -function onTimelineDragLeave (e) { - if (!e.currentTarget.contains(e.relatedTarget)) dropGhost.value = null -} -// ─── Tech drag (for assistant assignment) ───────────────────────────────────── -const dragTech = ref(null) -function onTechDragStart (e, tech) { - dragTech.value = tech - dragReorderTech.value = tech - e.dataTransfer.effectAllowed = 'copyMove' - e.dataTransfer.setData('text/plain', tech.id) - e.target.addEventListener('dragend', () => { dragTech.value = null; cleanupDropIndicators() }, { once: true }) -} -function onBlockDrop (e, job) { - if (dragTech.value) { - e.preventDefault() - e.stopPropagation() - cleanupDropIndicators() - pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' }) - store.addAssistant(job.id, dragTech.value.id) - dragTech.value = null - invalidateRoutes() - } - // For job reorder: don't stop or prevent — let it bubble to onTimelineDrop -} - - -// ─── Snap helper (5min grid) ────────────────────────────────────────────────── -// SNAP_MIN, SNAP, snapH imported from useHelpers - -// ─── Block horizontal move (drag to reposition in timeline) ─────────────────── -function startBlockMove (e, job, block) { - if (e.button !== 0) return - const startX = e.clientX - const startY = e.clientY - const startLeft = parseFloat(block.style.left) || 0 - let moving = false - - function onMove (ev) { - const dx = ev.clientX - startX - const dy = ev.clientY - startY - // If vertical movement > horizontal, abort (let HTML5 drag handle it) - if (!moving && Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 5) { cleanup(); return } - if (!moving && Math.abs(dx) > 5) { moving = true; block.style.zIndex = '10' } - if (!moving) return - ev.preventDefault() - const newLeft = Math.max(0, startLeft + dx) - const newH = snapH(H_START + newLeft / pxPerHr.value) - block.style.left = ((newH - H_START) * pxPerHr.value) + 'px' - const meta = block.querySelector('.sb-block-meta') - if (meta) meta.textContent = `${hToTime(newH)} · ${fmtDur(job.duration)}` - } - - function cleanup () { - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) - } - - function onUp (ev) { - cleanup() - if (!moving) return // was a click - block.style.zIndex = '' - const dx = ev.clientX - startX - const newLeft = Math.max(0, startLeft + dx) - const newH = snapH(H_START + newLeft / pxPerHr.value) - const time = hToTime(newH) - job.startHour = newH - job.startTime = time - store.setJobSchedule(job.id, job.scheduledDate, time) - invalidateRoutes() - } - - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) -} - -// ─── Block resize ───────────────────────────────────────────────────────────── -function startResize (e, job, mode, assistTechId) { - e.preventDefault() - const startX = e.clientX - const startDur = mode === 'assist' - ? (job.assistants.find(a => a.techId === assistTechId)?.duration || job.duration) - : job.duration - const block = e.target.parentElement - const startW = block.offsetWidth - - function onMove (ev) { - const dx = ev.clientX - startX - const newW = Math.max(18, startW + dx) - const newDur = Math.max(SNAP, snapH(newW / pxPerHr.value)) - block.style.width = (newDur * pxPerHr.value) + 'px' - // Live label update - const meta = block.querySelector('.sb-block-meta') - if (meta) { - meta.textContent = mode === 'assist' - ? `assistant · ${fmtDur(newDur)}` - : fmtDur(newDur) - } - } - - function onUp (ev) { - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) - const dx = ev.clientX - startX - const newW = Math.max(18, startW + dx) - const newDur = Math.max(SNAP, snapH(newW / pxPerHr.value)) - - if (mode === 'assist' && assistTechId) { - const assist = job.assistants.find(a => a.techId === assistTechId) - if (assist) { - assist.duration = newDur - updateJob(job.name || job.id, { - assistants: 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(() => {}) - } - } else { - job.duration = newDur - updateJob(job.name || job.id, { duration_h: newDur }).catch(() => {}) - } - invalidateRoutes() - } - - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) -} - -function assignDroppedJob (tech, dateStr) { - if (!dragJob.value) return - if (dragIsAssist.value) { - dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false - return - } - // Batch drag from bottom panel - if (dragBatchIds.value && dragBatchIds.value.size > 1) { - dragBatchIds.value.forEach(jobId => { - const j = store.jobs.find(x => x.id === jobId) - if (j && !j.assignedTech) { - pushUndo({ type: 'unassignJob', jobId: j.id, techId: j.assignedTech, routeOrder: j.routeOrder, scheduledDate: j.scheduledDate, assistants: [...(j.assistants||[])] }) - smartAssign(j, tech.id, dateStr) - } - }) - bottomSelected.value = new Set() - dragBatchIds.value = null - } else { - const job = dragJob.value - pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants||[])] }) - smartAssign(job, tech.id, dateStr) - } - dropGhost.value = null; dragJob.value = null; dragSrc.value = null - invalidateRoutes() -} -function onTimelineDrop (e, tech) { - e.preventDefault() - cleanupDropIndicators() - - // Tech dropped on timeline → check if over a block to add as assistant - if (dragTech.value) { - const els = document.elementsFromPoint(e.clientX, e.clientY) - const blockEl = els.find(el => el.dataset?.jobId) - if (blockEl) { - const job = store.jobs.find(j => j.id === blockEl.dataset.jobId) - if (job) { - pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' }) - store.addAssistant(job.id, dragTech.value.id) - dragTech.value = null - invalidateRoutes() - return - } - } - dragTech.value = null - return - } - - if (!dragJob.value) return - - // If dropping a job already on this tech → reorder based on drop X position - if (dragJob.value.assignedTech === tech.id) { - const rect = e.currentTarget.getBoundingClientRect() - const x = (e.clientX || e.pageX) - rect.left - const dropH = H_START + x / pxPerHr.value - const dayStr = localDateStr(periodStart.value) - - pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] }) - - // Remove dragged job from queue - const draggedJob = dragJob.value - tech.queue = tech.queue.filter(j => j.id !== draggedJob.id) - - // Find insert index: count how many remaining day-jobs have a rendered position before dropH - const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr) - let insertBefore = dayJobs.length // default: end - for (let i = 0; i < dayJobs.length; i++) { - // Use routeOrder as proxy for position - const globalIdx = tech.queue.indexOf(dayJobs[i]) - if (globalIdx >= 0) { - // Estimate this job's start position based on queue order - insertBefore = i - // We want to insert before the first job that would appear after the drop point - // Simple: use the midpoint of each block as threshold - break - } - } - - // Simpler approach: insert at position based on drop hour relative to block count - // Re-insert at the right queue position - const queueDayStart = tech.queue.findIndex(j => getJobDate(j.id) === dayStr) - const dayCount = dayJobs.length - // Map dropH to a slot index: divide the timeline into N+1 slots - let slot = dayCount // end by default - let cursor = 8 - for (let i = 0; i < dayJobs.length; i++) { - const dur = parseFloat(dayJobs[i].duration) || 1 - const midpoint = cursor + dur / 2 - if (dropH < midpoint) { slot = i; break } - cursor += dur + 0.5 // rough travel gap - } - - const insertAt = queueDayStart >= 0 ? queueDayStart + slot : tech.queue.length - tech.queue.splice(insertAt, 0, draggedJob) - - tech.queue.forEach((q, i) => { - q.routeOrder = i - updateJob(q.name || q.id, { route_order: i }).catch(() => {}) - }) - dragJob.value = null; dragSrc.value = null - invalidateRoutes() - return - } - - // Assist blocks can only reorder, not reassign - if (dragIsAssist.value) { - dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false - return - } - - // Otherwise: assign from unassigned or another tech - assignDroppedJob(tech, xToDateStr(e.clientX - e.currentTarget.getBoundingClientRect().left)) -} -function onCalDrop (e, tech, dateStr) { - assignDroppedJob(tech, dateStr) -} -function xToDateStr (x) { - const di = Math.max(0, Math.min(periodDays.value - 1, Math.floor(x / dayW.value))) - const d = new Date(periodStart.value); d.setDate(d.getDate() + di) - return localDateStr(d) -} - -// ─── Context menu ───────────────────────────────────────────────────────────── -const ctxMenu = ref(null) -function openCtxMenu (e, job, techId) { - e.preventDefault(); e.stopPropagation() - ctxMenu.value = { x: Math.min(e.clientX, window.innerWidth-180), y: Math.min(e.clientY, window.innerHeight-200), job, techId } -} -function closeCtxMenu () { ctxMenu.value = null } - -// ─── Assistant context menu ─────────────────────────────────────────────────── -const assistCtx = ref(null) -function openAssistCtx (e, job, techId) { - e.preventDefault(); e.stopPropagation() - assistCtx.value = { x: Math.min(e.clientX, window.innerWidth-200), y: Math.min(e.clientY, window.innerHeight-150), job, techId } -} -function assistCtxTogglePin () { - if (!assistCtx.value) return - const { job, techId } = assistCtx.value - 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(() => {}) - invalidateRoutes() - } - assistCtx.value = null -} -function assistCtxRemove () { - if (!assistCtx.value) return - store.removeAssistant(assistCtx.value.job.id, assistCtx.value.techId) - invalidateRoutes() - assistCtx.value = null -} -function assistCtxNote () { - if (!assistCtx.value) return - const { job, techId } = assistCtx.value - const assist = job.assistants.find(a => a.techId === techId) - assistNoteModal.value = { job, techId, note: assist?.note || '' } - assistCtx.value = null -} -const assistNoteModal = ref(null) -function confirmAssistNote () { - if (!assistNoteModal.value) return - const { job, techId, note } = assistNoteModal.value - 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(() => {}) - } - assistNoteModal.value = null -} - -function ctxDetails () { - const { job, techId } = ctxMenu.value - const tech = store.technicians.find(t => t.id === techId) - rightPanel.value = { mode: 'details', data: { job, tech } }; closeCtxMenu() -} -function ctxMove () { - const { job, techId } = ctxMenu.value - openMoveModal(job, techId); closeCtxMenu() -} -// Centralized unassign: clears assistants and rebuilds assistJobs -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)) }) - if (selectedJob.value?.job?.id === job.id) selectedJob.value = null - invalidateRoutes() -} - -// Smart assign: if new tech is an assistant, remove circular dependency +// ─── Smart assign & full unassign ───────────────────────────────────────────── function smartAssign (job, newTechId, dateStr) { - // Remove the new tech from assistants if they're listed if (job.assistants.some(a => a.techId === newTechId)) { job.assistants = job.assistants.filter(a => a.techId !== newTechId) updateJob(job.name || job.id, { @@ -787,20 +310,15 @@ function smartAssign (job, newTechId, dateStr) { store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) }) } -function ctxUnschedule () { - const { job } = ctxMenu.value - fullUnassign(job) - closeCtxMenu() +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)) }) + if (selectedJob.value?.job?.id === job.id) selectedJob.value = null + invalidateRoutes() } -// ─── Selection, Hover & Undo ───────────────────────────────────────────────── -const hoveredJobId = ref(null) -const selectedJob = ref(null) // { job, techId, isAssist?, assistTechId? } -const multiSelect = ref([]) // [{ job, techId, isAssist?, assistTechId? }] -const unassignDropActive = ref(false) -// ─── Undo composable ───────────────────────────────────────────────────────── -const { pushUndo, performUndo } = useUndo(store, invalidateRoutes) - // ─── Bottom panel composable ────────────────────────────────────────────────── const { bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize, @@ -809,217 +327,34 @@ const { btColWidths, btColW, startColResize, } = useBottomPanel(store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart }) -// Check if a tech row should be elevated (has a linked block for hovered/selected job) -function techHasLinkedJob (tech) { - const hId = hoveredJobId.value - const sId = selectedJob.value?.job?.id - // Hovering a lead block → elevate rows with assistant blocks for that job - if (hId && (tech.assistJobs || []).some(j => j.id === hId)) return true - // Hovering an assistant block → elevate the row of the lead tech - if (hId && tech.queue.some(j => j.id === hId)) return true - // Selected (non-assist) → elevate assistant rows - if (sId && !selectedJob.value?.isAssist && (tech.assistJobs || []).some(j => j.id === sId)) return true - // Selected assist → elevate lead row - if (sId && selectedJob.value?.isAssist && tech.queue.some(j => j.id === sId)) return true - return false -} - -// Check if this tech's row is the one being hovered (lead block with assistants) -function techIsHovered (tech) { - const hId = hoveredJobId.value - if (!hId) return false - const job = tech.queue.find(j => j.id === hId) - return job && job.assistants?.length > 0 -} +// ─── Selection composable ──────────────────────────────────────────────────── +const { + hoveredJobId, selectedJob, multiSelect, + selectJob: _selectJob, isJobMultiSelected, batchUnassign, batchMoveTo, + lasso, boardScroll, lassoStyle, startLasso, moveLasso, endLasso, + techHasLinkedJob, techIsHovered, +} = useSelection({ store, periodStart, smartAssign, invalidateRoutes, fullUnassign }) +// Wrap selectJob to pass rightPanel ref function selectJob (job, techId, isAssist = false, assistTechId = null, event = null) { - const entry = { job, techId, isAssist, assistTechId } - const isMulti = event && (event.ctrlKey || event.metaKey) - - if (isMulti) { - const idx = multiSelect.value.findIndex(s => s.job.id === job.id && s.isAssist === isAssist) - if (idx >= 0) multiSelect.value.splice(idx, 1) - else multiSelect.value.push(entry) - selectedJob.value = entry - } else { - multiSelect.value = [] - const same = selectedJob.value?.job?.id === job.id && selectedJob.value?.isAssist === isAssist && selectedJob.value?.assistTechId === assistTechId - selectedJob.value = same ? null : entry - // Single click → open right panel with details - if (!same) { - const tech = store.technicians.find(t => t.id === (techId || job.assignedTech)) - rightPanel.value = { mode: 'details', data: { job, tech: tech || null } } - } else { - rightPanel.value = null - } - } + _selectJob(job, techId, isAssist, assistTechId, event, rightPanel) } -function isJobMultiSelected (jobId, isAssist = false) { - return multiSelect.value.some(s => s.job.id === jobId && s.isAssist === isAssist) -} - -function batchUnassign () { - if (!multiSelect.value.length) return - multiSelect.value.forEach(s => { - if (s.isAssist && s.assistTechId) { - store.removeAssistant(s.job.id, s.assistTechId) - } else { - fullUnassign(s.job) - } - }) - multiSelect.value = [] - selectedJob.value = null - invalidateRoutes() -} - -// ─── Lasso selection ───────────────────────────────────────────────────────── -const lasso = ref(null) // { x1, y1, x2, y2 } in board-relative coords -const boardScroll = ref(null) - -const lassoStyle = computed(() => { - if (!lasso.value) return {} - const l = lasso.value - const left = Math.min(l.x1, l.x2) - const top = Math.min(l.y1, l.y2) - const width = Math.abs(l.x2 - l.x1) - const height = Math.abs(l.y2 - l.y1) - return { left: left + 'px', top: top + 'px', width: width + 'px', height: height + 'px' } +// ─── Drag & Drop composable ────────────────────────────────────────────────── +const { + dragJob, dragSrc, dragIsAssist, dropGhost, + onJobDragStart, onTimelineDragOver, onTimelineDragLeave, + onTechDragStart, + onTimelineDrop, onCalDrop, + startBlockMove, startResize, +} = useDragDrop({ + store, pxPerHr, dayW, periodStart, periodDays, H_START, + getJobDate, bottomSelected, + pushUndo, smartAssign, invalidateRoutes, }) -function startLasso (e) { - if (e.target.closest('.sb-block, .sb-chip, .sb-res-cell, .sb-travel-trail, button, input, select, a')) return - if (e.button !== 0) return - e.preventDefault() - // Click on empty space clears selection - if (!e.ctrlKey && !e.metaKey) { - if (selectedJob.value || multiSelect.value.length) { - selectedJob.value = null - multiSelect.value = [] - } - } - const rect = boardScroll.value.getBoundingClientRect() - const x = e.clientX - rect.left + boardScroll.value.scrollLeft - const y = e.clientY - rect.top + boardScroll.value.scrollTop - lasso.value = { x1: x, y1: y, x2: x, y2: y } -} - -function moveLasso (e) { - if (!lasso.value) return - e.preventDefault() - const rect = boardScroll.value.getBoundingClientRect() - lasso.value.x2 = e.clientX - rect.left + boardScroll.value.scrollLeft - lasso.value.y2 = e.clientY - rect.top + boardScroll.value.scrollTop -} - -function endLasso () { - if (!lasso.value) return - const l = lasso.value - const w = Math.abs(l.x2 - l.x1) - const h = Math.abs(l.y2 - l.y1) - - // Only select if the rectangle is meaningful (>10px) - if (w > 10 && h > 10) { - const boardRect = boardScroll.value.getBoundingClientRect() - const lassoLeft = Math.min(l.x1, l.x2) - boardScroll.value.scrollLeft + boardRect.left - const lassoTop = Math.min(l.y1, l.y2) - boardScroll.value.scrollTop + boardRect.top - const lassoRight = lassoLeft + w - const lassoBottom = lassoTop + h - - // Find all blocks that intersect the lasso rectangle - const blocks = boardScroll.value.querySelectorAll('.sb-block[data-job-id], .sb-chip') - const selected = [] - blocks.forEach(el => { - const r = el.getBoundingClientRect() - if (r.right > lassoLeft && r.left < lassoRight && r.bottom > lassoTop && r.top < lassoBottom) { - const jobId = el.dataset?.jobId - if (jobId) { - const job = store.jobs.find(j => j.id === jobId) - if (job) selected.push({ job, techId: job.assignedTech, isAssist: false, assistTechId: null }) - } - } - }) - - if (selected.length) { - multiSelect.value = selected - if (selected.length === 1) selectedJob.value = selected[0] - } - } - - lasso.value = null -} - -function batchMoveTo (techId) { - if (!multiSelect.value.length) return - const dayStr = localDateStr(periodStart.value) - multiSelect.value.filter(s => !s.isAssist).forEach(s => { - smartAssign(s.job, techId, dayStr) - }) - multiSelect.value = [] - selectedJob.value = null - invalidateRoutes() -} - - -function onDropUnassign (e) { - e.preventDefault() - if (dragJob.value) { fullUnassign(dragJob.value); dragJob.value = null; dragSrc.value = null } - unassignDropActive.value = false -} - -// ─── WO creation modal ──────────────────────────────────────────────────────── -const woModal = ref(null) -function openWoModal (prefillDate = null, prefillTech = null) { - woModal.value = { - subject: '', - address: '', - latitude: null, - longitude: null, - duration_h: 1, - priority: 'low', - note: '', - tags: [], - techId: prefillTech || '', - date: prefillDate || todayStr, - } -} -async function confirmWo () { - if (!woModal.value || !woModal.value.subject.trim()) return - const { subject, address, duration_h, priority, techId, date, latitude, longitude, note, tags } = woModal.value - const job = await store.createJob({ subject, address, duration_h, priority, assigned_tech: techId || null, scheduled_date: date || null, latitude: latitude || null, longitude: longitude || null, note: note || '', tags: tags.map(t => ({ tag: t })) }) - woModal.value = null -} - -// ─── Move modal ─────────────────────────────────────────────────────────────── -const moveModalOpen = ref(false) -const moveForm = ref(null) - -function openMoveModal (job, srcTechId) { - moveForm.value = { - job, srcTechId, - newTechId: srcTechId, - newDate: getJobDate(job.id) || todayStr, - } - moveModalOpen.value = true -} -async function confirmMove () { - if (!moveForm.value) return - const { job, srcTechId, newTechId, newDate } = moveForm.value - pushUndo({ type: 'unassignJob', jobId: job.id, techId: srcTechId, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants||[])] }) - if (newTechId !== srcTechId) { - smartAssign(job, newTechId, newDate) - } else { - store.setJobSchedule(job.id, newDate) - } - moveModalOpen.value = false - bookingOverlay.value = null - invalidateRoutes() -} - -// ─── Left overlay (booking details) ────────────────────────────────────────── -const bookingOverlay = ref(null) // { job, tech } or null - // ─── Map composable ────────────────────────────────────────────────────────── +let computeDayRoute = () => {}, drawMapMarkers = () => {}, drawSelectedRoute = () => {}, getMap = () => null const _map = useMap({ store, MAPBOX_TOKEN, TECH_COLORS, currentView, periodStart, filteredResources, mapVisible, @@ -1029,19 +364,127 @@ const _map = useMap({ }) const { mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, mapDragJob, startGeoFix, cancelGeoFix, startMapResize, initMap, selectTechOnBoard, destroyMap, loadMapboxCss } = _map -// Re-assign forward-declared functions so earlier code (invalidateRoutes, watchers) uses map composable computeDayRoute = _map.computeDayRoute drawMapMarkers = _map.drawMapMarkers drawSelectedRoute = _map.drawSelectedRoute getMap = _map.getMap -// ─── Auto-distribute (selected jobs only) ───────────────────────────────────── +// ─── Route invalidation ────────────────────────────────────────────────────── +function invalidateRoutes () { + routeLegs.value = {}; routeGeometry.value = {} + if (currentView.value === 'day') { + const ds = localDateStr(periodStart.value) + filteredResources.value.forEach(tech => computeDayRoute(tech, ds)) + } + nextTick(() => { if (getMap()) { drawMapMarkers(); drawSelectedRoute() } }) +} + +watch( + [currentView, () => anchorDate.value.getTime(), filteredResources], + () => { + if (currentView.value !== 'day') return + routeLegs.value = {}; routeGeometry.value = {} + const ds = localDateStr(periodStart.value) + filteredResources.value.forEach(tech => computeDayRoute(tech, ds)) + if (getMap()) { drawMapMarkers(); drawSelectedRoute() } + }, +) + +// ─── Context menus ──────────────────────────────────────────────────────────── +const ctxMenu = ref(null) +const techCtx = ref(null) +const assistCtx = ref(null) +const assistNoteModal = ref(null) + +function openCtxMenu (e, job, techId) { + e.preventDefault(); e.stopPropagation() + ctxMenu.value = { x: Math.min(e.clientX, window.innerWidth-180), y: Math.min(e.clientY, window.innerHeight-200), job, techId } +} +function closeCtxMenu () { ctxMenu.value = null } +function openTechCtx (e, tech) { + e.preventDefault(); e.stopPropagation() + techCtx.value = { x: Math.min(e.clientX, window.innerWidth-200), y: Math.min(e.clientY, window.innerHeight-200), tech } +} +function openAssistCtx (e, job, techId) { + e.preventDefault(); e.stopPropagation() + assistCtx.value = { x: Math.min(e.clientX, window.innerWidth-200), y: Math.min(e.clientY, window.innerHeight-150), job, techId } +} +function assistCtxTogglePin () { + if (!assistCtx.value) return + const { job, techId } = assistCtx.value + 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(() => {}) + invalidateRoutes() + } + assistCtx.value = null +} +function assistCtxRemove () { + if (!assistCtx.value) return + store.removeAssistant(assistCtx.value.job.id, assistCtx.value.techId) + invalidateRoutes(); assistCtx.value = null +} +function assistCtxNote () { + if (!assistCtx.value) return + const { job, techId } = assistCtx.value + const assist = job.assistants.find(a => a.techId === techId) + assistNoteModal.value = { job, techId, note: assist?.note || '' } + assistCtx.value = null +} +function confirmAssistNote () { + if (!assistNoteModal.value) return + const { job, techId, note } = assistNoteModal.value + 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(() => {}) + } + assistNoteModal.value = null +} + +function ctxDetails () { + const { job, techId } = ctxMenu.value + rightPanel.value = { mode: 'details', data: { job, tech: store.technicians.find(t => t.id === techId) } }; closeCtxMenu() +} +function ctxMove () { const { job, techId } = ctxMenu.value; openMoveModal(job, techId); closeCtxMenu() } +function ctxUnschedule () { fullUnassign(ctxMenu.value.job); closeCtxMenu() } + +// ─── Move modal ─────────────────────────────────────────────────────────────── +const moveModalOpen = ref(false) +const moveForm = ref(null) +function openMoveModal (job, srcTechId) { + moveForm.value = { job, srcTechId, newTechId: srcTechId, newDate: getJobDate(job.id) || todayStr } + moveModalOpen.value = true +} +async function confirmMove () { + if (!moveForm.value) return + const { job, srcTechId, newTechId, newDate } = moveForm.value + pushUndo({ type: 'unassignJob', jobId: job.id, techId: srcTechId, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants||[])] }) + if (newTechId !== srcTechId) smartAssign(job, newTechId, newDate) + else store.setJobSchedule(job.id, newDate) + moveModalOpen.value = false; bookingOverlay.value = null; invalidateRoutes() +} + +// ─── Booking overlay ────────────────────────────────────────────────────────── +const bookingOverlay = ref(null) + +// ─── WO creation modal ──────────────────────────────────────────────────────── +const woModal = ref(null) +function openWoModal (prefillDate = null, prefillTech = null) { + woModal.value = { subject: '', address: '', latitude: null, longitude: null, duration_h: 1, priority: 'low', note: '', tags: [], techId: prefillTech || '', date: prefillDate || todayStr } +} +async function confirmWo () { + if (!woModal.value || !woModal.value.subject.trim()) return + const { subject, address, duration_h, priority, techId, date, latitude, longitude, note, tags } = woModal.value + await store.createJob({ subject, address, duration_h, priority, assigned_tech: techId || null, scheduled_date: date || null, latitude: latitude || null, longitude: longitude || null, note: note || '', tags: tags.map(t => ({ tag: t })) }) + woModal.value = null +} + +// ─── Auto-distribute ───────────────────────────────────────────────────────── async function autoDistribute () { const techs = filteredResources.value if (!techs.length) return - - // Selected → assign each to their own scheduledDate (or today if none) - // No selection → only today's unassigned jobs const today = todayStr let pool if (bottomSelected.value.size) { @@ -1051,205 +494,126 @@ async function autoDistribute () { } 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 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 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 dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr) - if (dayJobs.length) { const last = dayJobs[dayJobs.length - 1]; if (last.coords && last.coords[0] !== 0) return last.coords } + 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 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 }) - + 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 - const load = techLoadForDay(tech, assignDay) - const travelH = dist(techLastPosForDay(tech, assignDay), job.coords) / 60 - if (weights.balance) score += load * (weights.balance || 1) - if (weights.proximity) score += travelH * (weights.proximity || 1) - if (weights.skills && useSkills) { - const jobTags = job.tags || [], techTags = tech.tags || [] - const matched = jobTags.filter(t => techTags.includes(t)).length - score += (jobTags.length > 0 ? (jobTags.length - matched) * 2 : 0) * (weights.skills || 1) - } + 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() + bottomSelected.value = new Set(); invalidateRoutes() } -// ─── Tech context menu (was deleted during map extraction) ──────────────────── -const techCtx = ref(null) -function openTechCtx (e, tech) { - e.preventDefault(); e.stopPropagation() - techCtx.value = { x: Math.min(e.clientX, window.innerWidth - 220), y: Math.min(e.clientY, window.innerHeight - 120), tech } -} - -// ─── Optimize route for a single tech ───────────────────────────────────────── +// ─── Optimize route ─────────────────────────────────────────────────────────── async function optimizeRoute () { 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) 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 bestIdx = 0, bestDist = Infinity - remaining.forEach((j, i) => { const dx = j.coords[0] - cur[0], dy = j.coords[1] - cur[1]; const d = dx*dx + dy*dy; if (d < bestDist) { bestDist = d; bestIdx = i } }) - const picked = remaining.splice(bestIdx, 1)[0]; result.push(picked); cur = picked.coords - } - return result - } + 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 lastPos = orderedUrgent.length ? orderedUrgent[orderedUrgent.length - 1].coords : home - const orderedNormal = nearestNeighbor(lastPos, normal) + 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]}`)) + 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 offset = hasHome ? 1 : 0 - const urgentCount = orderedUrgent.length - const mapboxUrgent = reordered.slice(0, urgentCount).map((job, i) => ({ job, optOrder: data.waypoints[i + offset].waypoint_index })).sort((a, b) => a.optOrder - b.optOrder).map(x => x.job) - const mapboxNormal = reordered.slice(urgentCount).map((job, i) => ({ job, optOrder: data.waypoints[i + urgentCount + offset].waypoint_index })).sort((a, b) => a.optOrder - b.optOrder).map(x => x.job) - reordered.length = 0; reordered.push(...mapboxUrgent, ...mapboxNormal) + 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((job, i) => { job.routeOrder = i; updateJob(job.name || job.id, { route_order: i, start_time: '' }).catch(() => {}) }) + tech.queue.forEach((j, i) => { j.routeOrder = i; updateJob(j.name || j.id, { route_order: i, start_time: '' }).catch(() => {}) }) invalidateRoutes() } -// ─── Board tabs (visual, not implemented as routes) ─────────────────────────── +// ─── Board tabs ─────────────────────────────────────────────────────────────── const boardTabs = ref(['Vue principale','Par région']) const activeTab = ref('Vue principale') -// stOf, prioLabel, prioClass, jobStatusIcon imported from useHelpers +// ─── Unassign drop handler ──────────────────────────────────────────────────── +function onDropUnassign (e) { + e.preventDefault() + if (dragJob.value) { fullUnassign(dragJob.value); dragJob.value = null; dragSrc.value = null } + unassignDropActive.value = false +} // ─── Keyboard ───────────────────────────────────────────────────────────────── function onKeyDown (e) { if (e.key === 'Escape') { - closeCtxMenu(); assistCtx.value = null; techCtx.value = null; assistNoteModal.value = null; moveModalOpen.value = false; resSelectorOpen.value = false - rightPanel.value = null; timeModal.value = null; woModal.value = null; editModal.value = null; dispatchCriteriaModal.value = false - bookingOverlay.value = null; sidebarFlyout.value = null - selectedJob.value = null - multiSelect.value = [] + closeCtxMenu(); assistCtx.value = null; techCtx.value = null; assistNoteModal.value = null + moveModalOpen.value = false; resSelectorOpen.value = false; rightPanel.value = null + timeModal.value = null; woModal.value = null; editModal.value = null + dispatchCriteriaModal.value = false; bookingOverlay.value = null; sidebarFlyout.value = null + selectedJob.value = null; multiSelect.value = [] } - // Undo: CMD+Z / CTRL+Z if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) return - e.preventDefault() - performUndo() - return + e.preventDefault(); performUndo(); return } - // Delete/Backspace: remove selected if ((e.key === 'Delete' || e.key === 'Backspace') && (selectedJob.value || multiSelect.value.length)) { if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) return e.preventDefault() - // Multi-select delete if (multiSelect.value.length) { batchUnassign(); return } - const { job, techId, isAssist, assistTechId } = selectedJob.value + const { job, isAssist, assistTechId } = selectedJob.value if (isAssist && assistTechId) { const assist = job.assistants.find(a => a.techId === assistTechId) pushUndo({ type: 'removeAssistant', jobId: job.id, techId: assistTechId, duration: assist?.duration || 0, note: assist?.note || '' }) store.removeAssistant(job.id, assistTechId) - } else { - fullUnassign(job) - } - selectedJob.value = null - invalidateRoutes() + } else { fullUnassign(job) } + selectedJob.value = null; invalidateRoutes() } } -// ─── Refresh (preserves view state) ────────────────────────────────────────── +// ─── Refresh ────────────────────────────────────────────────────────────────── async function refreshData () { - // Save current UI state before reload - const prevTechId = selectedTechId.value - + const prevTechId = selectedTechId.value await store.loadAll() - - // Re-apply locally geo-fixed coordinates (overrides ERPNext values) const savedCoords = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}') - Object.entries(savedCoords).forEach(([jobId, coords]) => { - const job = store.jobs.find(j => j.id === jobId) - if (job) job.coords = coords - }) - - // Recompute routes with fresh objects + correct coords - routeLegs.value = {} - routeGeometry.value = {} + Object.entries(savedCoords).forEach(([jobId, coords]) => { const job = store.jobs.find(j => j.id === jobId); if (job) job.coords = coords }) + routeLegs.value = {}; routeGeometry.value = {} const _ds = localDateStr(periodStart.value) filteredResources.value.forEach(tech => computeDayRoute(tech, _ds)) - - // Restore booking overlay with fresh object references - const prevBookingJobId = bookingOverlay.value?.job?.id - const prevBookingTechId = bookingOverlay.value?.tech?.id - if (prevBookingJobId) { - const j = store.jobs.find(j => j.id === prevBookingJobId) - const t = prevBookingTechId ? store.technicians.find(t => t.id === prevBookingTechId) : null - if (j) bookingOverlay.value = { job: j, tech: t } - } - - // Restore selected tech + const pj = bookingOverlay.value?.job?.id, pt = bookingOverlay.value?.tech?.id + if (pj) { const j = store.jobs.find(j => j.id === pj); const t = pt ? store.technicians.find(t => t.id === pt) : null; if (j) bookingOverlay.value = { job: j, tech: t } } selectedTechId.value = prevTechId - if (getMap()) { drawMapMarkers(); drawSelectedRoute() } - await loadPendingReqs() } -// ─── Provide shared state for child components ─────────────────────────────── +// ─── Provide for child components ───────────────────────────────────────────── provide('store', store) provide('TECH_COLORS', TECH_COLORS) provide('MAPBOX_TOKEN', MAPBOX_TOKEN) @@ -1269,50 +633,35 @@ provide('selectAddr', selectAddr) // ─── Lifecycle ──────────────────────────────────────────────────────────────── onMounted(async () => { if (!store.technicians.length) await store.loadAll() - // Apply any locally geo-fixed coordinates (overrides ERPNext defaults) const savedCoords = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}') - Object.entries(savedCoords).forEach(([jobId, coords]) => { - const job = store.jobs.find(j => j.id === jobId) - if (job) job.coords = coords - }) - // Routes were computed before coords were applied — clear cache and redo - routeLegs.value = {} - routeGeometry.value = {} + Object.entries(savedCoords).forEach(([jobId, coords]) => { const job = store.jobs.find(j => j.id === jobId); if (job) job.coords = coords }) + routeLegs.value = {}; routeGeometry.value = {} const _ds = localDateStr(periodStart.value) filteredResources.value.forEach(tech => computeDayRoute(tech, _ds)) await loadPendingReqs() document.addEventListener('keydown', onKeyDown) document.addEventListener('click', () => { closeCtxMenu(); assistCtx.value = null; techCtx.value = null }) - // load mapbox css if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id='mapbox-css'; l.rel='stylesheet' l.href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) } }) -onUnmounted(() => { - document.removeEventListener('keydown', onKeyDown) - document.removeEventListener('click', closeCtxMenu) - destroyMap() -}) +onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('click', closeCtxMenu); destroyMap() })