Major dispatch/task system overhaul: - Project templates with 3-step wizard (choose template → edit steps → publish) - 4 built-in templates: phone service, fiber install, move, repair - Nested task tree with recursive TaskNode component (parent_job hierarchy) - n8n webhook integration (on_open_webhook, on_close_webhook per task) - Inline task editing: status, priority, type, tech assignment, tags, delete - Tech assignment + tags from ticket modal → jobs appear on dispatch timeline - ERPNext custom fields: parent_job, on_open_webhook, on_close_webhook, step_order - Refactored ClientDetailPage, ChatterPanel, DetailModal, dispatch store - CSS consolidation, dead code cleanup, composable extraction - Dashboard KPIs with dispatch integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
958 lines
58 KiB
Vue
958 lines
58 KiB
Vue
<script setup>
|
||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
|
||
import { useDispatchStore } from 'src/stores/dispatch'
|
||
import { useAuthStore } from 'src/stores/auth'
|
||
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
|
||
import { fetchOpenRequests } from 'src/api/service-request'
|
||
import { updateJob } from 'src/api/dispatch'
|
||
|
||
// ── Components ──────────────────────────────────────────────────────────────────
|
||
import TagEditor from 'src/components/shared/TagEditor.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, timeToH, fmtDur,
|
||
SVC_COLORS, prioLabel, prioClass, serializeAssistants,
|
||
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'
|
||
import { useAutoDispatch } from 'src/composables/useAutoDispatch'
|
||
import { usePeriodNavigation } from 'src/composables/usePeriodNavigation'
|
||
import { useResourceFilter } from 'src/composables/useResourceFilter'
|
||
import { useTagManagement } from 'src/composables/useTagManagement'
|
||
import { useContextMenus } from 'src/composables/useContextMenus'
|
||
import { useTechManagement } from 'src/composables/useTechManagement'
|
||
|
||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||
const store = useDispatchStore()
|
||
const auth = useAuthStore()
|
||
const erpUrl = BASE_URL || window.location.origin
|
||
|
||
// ─── Period Navigation ───────────────────────────────────────────────────────
|
||
const {
|
||
currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr,
|
||
prevPeriod, nextPeriod, goToToday, goToDay,
|
||
} = usePeriodNavigation()
|
||
|
||
// ─── Resource Filter ─────────────────────────────────────────────────────────
|
||
const {
|
||
selectedResIds, filterStatus, filterTags, searchQuery, techSort, manualOrder,
|
||
filteredResources, resSelectorOpen, tempSelectedIds, dragReorderTech,
|
||
openResSelector, applyResSelector, toggleTempRes, clearFilters,
|
||
onTechReorderStart, onTechReorderDrop,
|
||
} = useResourceFilter(store)
|
||
|
||
// ─── Tags ────────────────────────────────────────────────────────────────────
|
||
const techTagModal = ref(null)
|
||
const {
|
||
getTagColor, onCreateTag, onUpdateTag, onRenameTag, onDeleteTag,
|
||
_serializeTags, persistJobTags, persistTechTags,
|
||
} = useTagManagement(store)
|
||
|
||
// ─── Address autocomplete ────────────────────────────────────────────────────
|
||
const addrResults = ref([])
|
||
const addrLoading = ref(false)
|
||
let addrDebounce = null
|
||
function searchAddr (q) {
|
||
clearTimeout(addrDebounce)
|
||
if (!q || q.length < 3) { addrResults.value = []; return }
|
||
addrLoading.value = true
|
||
addrDebounce = setTimeout(async () => {
|
||
try {
|
||
const res = await fetch(`/api/method/search_address?q=${encodeURIComponent(q)}`, { credentials: 'include' })
|
||
const data = await res.json()
|
||
addrResults.value = data.results || data.message?.results || []
|
||
} catch { addrResults.value = [] }
|
||
addrLoading.value = false
|
||
}, 300)
|
||
}
|
||
function selectAddr (addr, target) {
|
||
if (!target) { addrResults.value = []; return }
|
||
target.address = addr.address_full
|
||
if (addr.latitude) target.latitude = parseFloat(addr.latitude)
|
||
if (addr.longitude) target.longitude = parseFloat(addr.longitude)
|
||
if (addr.ville) target.ville = addr.ville
|
||
if (addr.code_postal) target.code_postal = addr.code_postal
|
||
addrResults.value = []
|
||
}
|
||
|
||
function setEndDate (job, endDate) {
|
||
job.endDate = endDate || null
|
||
updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {})
|
||
}
|
||
|
||
// ─── Layout state ─────────────────────────────────────────────────────────────
|
||
const filterPanelOpen = ref(false)
|
||
const projectsPanelOpen = ref(false)
|
||
const mapVisible = ref(localStorage.getItem('sbv2-map') === 'true')
|
||
const rightPanel = ref(null)
|
||
|
||
watch(mapVisible, v => localStorage.setItem('sbv2-map', v ? 'true' : 'false'))
|
||
|
||
// ─── Edit modal (double-click) ───────────────────────────────────────────────
|
||
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,
|
||
}
|
||
}
|
||
function confirmEdit () {
|
||
if (!editModal.value) return
|
||
const { job, subject, address, note, duration, priority, tags, latitude, longitude } = editModal.value
|
||
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, {
|
||
subject, address, note: note || '', duration_h: job.duration, priority,
|
||
tags: tags.map(t => ({ tag: t })),
|
||
latitude: latitude || null, longitude: longitude || null,
|
||
}).catch(() => {})
|
||
editModal.value = null
|
||
invalidateRoutes()
|
||
}
|
||
|
||
// ─── 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
|
||
}
|
||
|
||
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 }
|
||
|
||
// ─── Unscheduled / Pending ────────────────────────────────────────────────────
|
||
const pendingReqs = ref([])
|
||
const pendingLoading = ref(false)
|
||
async function loadPendingReqs () {
|
||
pendingLoading.value = true
|
||
try { pendingReqs.value = await fetchOpenRequests() } catch { pendingReqs.value = [] }
|
||
pendingLoading.value = false
|
||
}
|
||
const unscheduledJobs = computed(() => store.jobs.filter(j => !j.assignedTech))
|
||
const teamJobs = computed(() => store.jobs.filter(j => j.assistants?.length > 0))
|
||
|
||
// ─── Job color ────────────────────────────────────────────────────────────────
|
||
function jobColor (job) { return _jobColorBase(job, TECH_COLORS, store) }
|
||
|
||
// ─── Timeline geometry ────────────────────────────────────────────────────────
|
||
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(() => 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,
|
||
periodLoadH,
|
||
} = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor)
|
||
|
||
const hourTicks = computed(() => {
|
||
if (currentView.value === 'month') return []
|
||
const ticks = []
|
||
dayColumns.value.forEach((day, di) => {
|
||
for (let h = H_START; h <= H_END; h++) {
|
||
const x = di * dayW.value + (h - H_START) * pxPerHr.value
|
||
ticks.push({ x, label: h===H_START||h%2===0 ? h+':00' : null, isMajor: true, isDay: h===H_START, day, h })
|
||
}
|
||
})
|
||
return ticks
|
||
})
|
||
|
||
const isCalView = computed(() => currentView.value === 'week')
|
||
const unassignDropActive = ref(false)
|
||
|
||
// ─── Undo composable ─────────────────────────────────────────────────────────
|
||
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
|
||
|
||
// ─── 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] })
|
||
store.fullUnassign(job.id)
|
||
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
|
||
invalidateRoutes()
|
||
}
|
||
|
||
// ─── Context menus ────────────────────────────────────────────────────────────
|
||
const {
|
||
ctxMenu, techCtx, assistCtx, assistNoteModal,
|
||
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
|
||
assistCtxTogglePin, assistCtxRemove, assistCtxNote, confirmAssistNote,
|
||
ctxDetails, ctxMove, ctxUnschedule,
|
||
} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal })
|
||
|
||
// ─── Bottom panel composable ──────────────────────────────────────────────────
|
||
const {
|
||
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
|
||
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
|
||
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
|
||
btColWidths, btColW, startColResize,
|
||
} = useBottomPanel(store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart })
|
||
|
||
// ─── 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 })
|
||
|
||
function selectJob (job, techId, isAssist = false, assistTechId = null, event = null) {
|
||
_selectJob(job, techId, isAssist, assistTechId, event, rightPanel)
|
||
}
|
||
|
||
// ─── Drag & Drop composable ──────────────────────────────────────────────────
|
||
const {
|
||
dragJob, dragSrc, dragIsAssist, dragBatchIds, dropGhost,
|
||
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
|
||
onTechDragStart,
|
||
onTimelineDrop, onCalDrop,
|
||
startBlockMove, startResize,
|
||
} = useDragDrop({
|
||
store, pxPerHr, dayW, periodStart, periodDays, H_START,
|
||
getJobDate, bottomSelected, multiSelect,
|
||
pushUndo, smartAssign, invalidateRoutes,
|
||
})
|
||
|
||
// ─── Map composable ──────────────────────────────────────────────────────────
|
||
let computeDayRoute = () => {}, drawMapMarkers = () => {}, drawSelectedRoute = () => {}, getMap = () => null
|
||
const _map = useMap({
|
||
store, MAPBOX_TOKEN, TECH_COLORS,
|
||
currentView, periodStart, filteredResources, mapVisible,
|
||
routeLegs, routeGeometry,
|
||
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
|
||
dragJob, dragIsAssist, rightPanel, openCtxMenu,
|
||
})
|
||
const { mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, mapDragJob,
|
||
startGeoFix, cancelGeoFix, startMapResize, initMap, selectTechOnBoard, destroyMap, loadMapboxCss } = _map
|
||
computeDayRoute = _map.computeDayRoute
|
||
drawMapMarkers = _map.drawMapMarkers
|
||
drawSelectedRoute = _map.drawSelectedRoute
|
||
getMap = _map.getMap
|
||
|
||
// ─── 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() }
|
||
},
|
||
)
|
||
|
||
// ─── 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)
|
||
const gpsSettingsOpen = ref(false)
|
||
|
||
// ─── Tech management (GPS modal) ─────────────────────────────────────────────
|
||
const {
|
||
editingTech, newTechName, newTechPhone, newTechDevice, addingTech,
|
||
saveTechField, addTech, removeTech, saveTraccarLink,
|
||
} = useTechManagement(store, invalidateRoutes)
|
||
|
||
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-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
|
||
_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 ───────────────────────────────────────────────────────────────
|
||
const boardTabs = ref(['Vue principale','Par région'])
|
||
const activeTab = ref('Vue principale')
|
||
|
||
// ─── Unassign drop handler ────────────────────────────────────────────────────
|
||
function onDropUnassign (e) {
|
||
e.preventDefault()
|
||
if (dragJob.value) { fullUnassign(dragJob.value); dragJob.value = null; dragSrc.value = null }
|
||
unassignDropActive.value = false
|
||
}
|
||
|
||
// ─── Click empty space = deselect all ─────────────────────────────────────────
|
||
let _lassoJustEnded = false
|
||
function onRootClick (e) {
|
||
if (_lassoJustEnded) { _lassoJustEnded = false; return }
|
||
const interactive = e.target.closest('.sb-block, .sb-chip, .sb-bottom-row, .sb-bottom-hdr, button, input, select, a, .sb-ctx-menu, .sb-right-panel, .sb-wo-modal, .sb-edit-modal, .sb-criteria-modal, .sb-gps-modal, .sb-modal-overlay, .sb-multi-bar, .sb-toolbar-panel, .sb-header, .sb-bt-checkbox, .sb-res-cell, .sb-bottom-date-sep')
|
||
if (interactive) return
|
||
if (selectedJob.value || multiSelect.value.length || bottomSelected.size || rightPanel.value) {
|
||
selectedJob.value = null
|
||
multiSelect.value = []
|
||
clearBottomSelect()
|
||
rightPanel.value = null
|
||
}
|
||
}
|
||
|
||
// ─── 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
|
||
filterPanelOpen.value = false; projectsPanelOpen.value = false
|
||
selectedJob.value = null; multiSelect.value = []
|
||
}
|
||
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
||
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) return
|
||
e.preventDefault(); performUndo(); return
|
||
}
|
||
if ((e.key === 'Delete' || e.key === 'Backspace') && (selectedJob.value || multiSelect.value.length)) {
|
||
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) return
|
||
e.preventDefault()
|
||
if (multiSelect.value.length) { batchUnassign(pushUndo); return }
|
||
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()
|
||
}
|
||
}
|
||
|
||
// ─── Refresh ──────────────────────────────────────────────────────────────────
|
||
async function refreshData () {
|
||
const prevTechId = selectedTechId.value
|
||
await store.loadAll()
|
||
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 })
|
||
routeLegs.value = {}; routeGeometry.value = {}
|
||
const _ds = localDateStr(periodStart.value)
|
||
filteredResources.value.forEach(tech => computeDayRoute(tech, _ds))
|
||
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 for child components ─────────────────────────────────────────────
|
||
provide('store', store)
|
||
provide('TECH_COLORS', TECH_COLORS)
|
||
provide('MAPBOX_TOKEN', MAPBOX_TOKEN)
|
||
provide('jobColor', jobColor)
|
||
provide('getTagColor', getTagColor)
|
||
provide('onCreateTag', onCreateTag)
|
||
provide('onUpdateTag', onUpdateTag)
|
||
provide('onRenameTag', onRenameTag)
|
||
provide('onDeleteTag', onDeleteTag)
|
||
provide('selectedJob', selectedJob)
|
||
provide('hoveredJobId', hoveredJobId)
|
||
provide('periodLoadH', periodLoadH)
|
||
provide('isJobMultiSelected', isJobMultiSelected)
|
||
provide('btColW', btColW)
|
||
provide('startColResize', startColResize)
|
||
provide('searchAddr', searchAddr)
|
||
provide('addrResults', addrResults)
|
||
provide('selectAddr', selectAddr)
|
||
|
||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||
onMounted(async () => {
|
||
if (!store.technicians.length) await store.loadAll()
|
||
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 })
|
||
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 })
|
||
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)
|
||
}
|
||
store.startGpsTracking()
|
||
})
|
||
onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('click', closeCtxMenu); destroyMap(); store.stopGpsTracking() })
|
||
</script>
|
||
|
||
<template>
|
||
<div class="sb-root" @click="onRootClick">
|
||
|
||
<!-- ══ DISPATCH TOOLBAR (replaces old header + sidebar) ══ -->
|
||
<header class="sb-header">
|
||
<div class="sb-header-left">
|
||
<input class="sb-search" v-model="searchQuery" placeholder="🔍 Ressource…" />
|
||
<div class="sb-tabs">
|
||
<button v-for="tab in boardTabs" :key="tab" class="sb-tab" :class="{ active: activeTab===tab }" @click="activeTab=tab">{{ tab }}</button>
|
||
<button class="sb-tab sb-tab-add" title="Nouvelle vue">+</button>
|
||
</div>
|
||
<button class="sb-icon-btn" :class="{ active: filterPanelOpen }" @click="filterPanelOpen=!filterPanelOpen" title="Filtres & Ressources">
|
||
<span v-html="ICON.wrench"></span>
|
||
<span v-if="filterStatus||selectedResIds.length||filterTags.length" class="sbs-badge" style="position:relative;top:-4px;right:2px"></span>
|
||
</button>
|
||
<button v-if="teamJobs.length" class="sb-icon-btn" :class="{ active: projectsPanelOpen }" @click="projectsPanelOpen=!projectsPanelOpen" title="Projets">
|
||
👥 <span class="sbs-count" style="position:relative;top:-2px;right:auto">{{ teamJobs.length }}</span>
|
||
</button>
|
||
</div>
|
||
<div class="sb-header-center">
|
||
<button class="sb-hbtn sb-today-btn" @click="goToToday">Aujourd'hui</button>
|
||
<button class="sb-hbtn" @click="prevPeriod">‹</button>
|
||
<span class="sb-period-label">{{ periodLabel }}</span>
|
||
<button class="sb-hbtn" @click="nextPeriod">›</button>
|
||
<div class="sb-view-sw">
|
||
<button :class="{ active: currentView==='day' }" @click="currentView='day'">Jour</button>
|
||
<button :class="{ active: currentView==='week' }" @click="currentView='week'">Semaine</button>
|
||
<button :class="{ active: currentView==='month' }" @click="currentView='month'">Mois</button>
|
||
</div>
|
||
</div>
|
||
<div class="sb-header-right">
|
||
<button class="sb-icon-btn" :class="{ active: bottomPanelOpen }" @click="bottomPanelOpen=!bottomPanelOpen" title="Jobs non assignées">
|
||
📋 <span v-if="unscheduledJobs.length" class="sbs-count" style="position:relative;top:-2px;right:auto">{{ unscheduledJobs.length }}</span>
|
||
</button>
|
||
<button v-if="currentView==='day'" class="sb-icon-btn" :class="{ active: mapVisible }" @click="mapVisible=!mapVisible" title="Carte">🗺 Carte</button>
|
||
<button class="sb-icon-btn" @click="refreshData()" title="Actualiser">↻</button>
|
||
<button class="sb-icon-btn" @click="gpsSettingsOpen=true" title="GPS Tracking">📡</button>
|
||
<button class="sb-wo-btn" @click="openWoModal()" title="Nouveau work order">+ WO</button>
|
||
<a class="sb-erp-link" :href="erpUrl + '/desk'" target="_blank" title="Ouvrir ERPNext">ERP</a>
|
||
<div class="sb-erp-dot" :class="{ ok: store.erpStatus==='ok' }" :title="{ ok:'ERPNext ✓', error:'Hors ligne', loading:'Connexion…' }[store.erpStatus]||'ERPNext'"></div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ══ FILTER / PROJECTS PANELS (dropdown from toolbar) ══ -->
|
||
<transition name="sb-slide-down">
|
||
<div v-if="filterPanelOpen" class="sb-toolbar-panel" @click.stop>
|
||
<div class="sb-toolbar-panel-inner">
|
||
<div class="sbf-section" style="border-bottom:none;display:flex;gap:1rem;flex-wrap:wrap;padding:0.5rem 0.75rem">
|
||
<div style="min-width:180px">
|
||
<div class="sbf-title">Ressources</div>
|
||
<input class="sbs-search-full" v-model="searchQuery" placeholder="🔍 Rechercher une ressource…" />
|
||
<button class="sbf-primary-btn" style="margin-top:0.3rem" @click="openResSelector">Sélectionner les ressources</button>
|
||
<label class="sbf-lbl" style="margin-top:0.3rem">Tri</label>
|
||
<select class="sbf-select" v-model="techSort">
|
||
<option value="default">Par défaut</option><option value="alpha">Alphabétique (nom)</option><option value="manual">Manuel (drag)</option>
|
||
</select>
|
||
<div v-if="selectedResIds.length" class="sbf-chip">{{ selectedResIds.length }} sélectionnée{{ selectedResIds.length>1?'s':'' }}<button @click="selectedResIds=[]">✕</button></div>
|
||
</div>
|
||
<div style="min-width:180px">
|
||
<div class="sbf-title">Filtres</div>
|
||
<label class="sbf-lbl">Statut</label>
|
||
<select class="sbf-select" v-model="filterStatus">
|
||
<option value="">Tous</option><option value="available">Disponible</option><option value="en-route">En route</option><option value="busy">En cours</option><option value="off">Hors shift</option>
|
||
</select>
|
||
<label class="sbf-lbl">Tags</label>
|
||
<TagEditor :model-value="filterTags" @update:model-value="v => { filterTags = v; localStorage.setItem('sbv2-filterTags', JSON.stringify(v)) }"
|
||
:all-tags="store.allTags" :get-color="getTagColor" :can-create="false" :can-edit="false" placeholder="Filtrer par tag…" />
|
||
</div>
|
||
<div style="display:flex;align-items:flex-end;margin-left:auto">
|
||
<button v-if="filterStatus||selectedResIds.length||searchQuery||filterTags.length" class="sbf-clear-btn" style="width:auto;padding:0.22rem 0.6rem" @click="clearFilters">✕ Réinitialiser</button>
|
||
<button class="sb-icon-btn" style="margin-left:0.3rem" @click="filterPanelOpen=false" title="Fermer">✕</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<transition name="sb-slide-down">
|
||
<div v-if="projectsPanelOpen" class="sb-toolbar-panel" @click.stop>
|
||
<div class="sb-toolbar-panel-inner" style="max-height:300px;overflow-y:auto">
|
||
<div style="display:flex;align-items:center;padding:0.4rem 0.75rem;gap:0.5rem">
|
||
<div class="sbf-title" style="margin:0">Projets <span class="sbf-count">{{ teamJobs.length }}</span></div>
|
||
<div style="flex:1"></div>
|
||
<button class="sb-icon-btn" @click="projectsPanelOpen=false" title="Fermer">✕</button>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem;padding:0 0.75rem 0.5rem">
|
||
<div v-for="job in teamJobs" :key="'tj-'+job.id" class="sbf-card" style="width:220px"
|
||
@click="rightPanel={ mode:'details', data:{ job, tech: store.technicians.find(t=>t.id===job.assignedTech) } }">
|
||
<div class="sbf-card-stripe" :style="'background:'+jobColor(job)"></div>
|
||
<div class="sbf-card-body">
|
||
<div class="sbf-card-title">{{ job.subject }}</div>
|
||
<div class="sbf-card-meta">⏱ {{ fmtDur(job.duration) }} · {{ job.scheduledDate || '—' }}</div>
|
||
<div class="sbf-team-badges">
|
||
<span class="sb-assist-badge sb-assist-badge-lead" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===job.assignedTech)?.colorIdx||0]">
|
||
{{ (store.technicians.find(t=>t.id===job.assignedTech)?.fullName||'?').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
||
</span>
|
||
<span v-for="a in job.assistants" :key="a.techId" class="sb-assist-badge" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===a.techId)?.colorIdx||0]" :title="a.techName">
|
||
{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- ══ BODY ══ -->
|
||
<div class="sb-body">
|
||
|
||
<!-- Booking overlay (left) -->
|
||
<div v-if="bookingOverlay" class="sb-overlay-backdrop" @click.self="bookingOverlay=null"></div>
|
||
<transition name="sb-slide-left">
|
||
<div v-if="bookingOverlay" class="sb-left-overlay" @click.stop="()=>{}">
|
||
<div class="sb-rp-hdr"><span class="sb-rp-title">Réservation</span><button class="sb-rp-close" @click="bookingOverlay=null">✕</button></div>
|
||
<div class="sb-rp-body">
|
||
<div class="sb-rp-color-bar" :style="'background:'+jobColor(bookingOverlay.job||{})"></div>
|
||
<div class="sb-rp-field"><span class="sb-rp-lbl">Titre</span><strong>{{ bookingOverlay.job?.subject }}</strong></div>
|
||
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ bookingOverlay.job?.address }}</div>
|
||
<div class="sb-rp-field"><span class="sb-rp-lbl">Durée</span>{{ fmtDur(bookingOverlay.job?.duration) }}</div>
|
||
<div class="sb-rp-field"><span class="sb-rp-lbl">Priorité</span><span :class="prioClass(bookingOverlay.job?.priority)">{{ prioLabel(bookingOverlay.job?.priority) }}</span></div>
|
||
<div class="sb-rp-field"><span class="sb-rp-lbl">Technicien</span>{{ bookingOverlay.tech?.fullName || '—' }}</div>
|
||
<div class="sb-rp-field"><span class="sb-rp-lbl">Date planifiée</span>{{ bookingOverlay.job?.scheduledDate || '—' }}<span v-if="bookingOverlay.job?.endDate"> → {{ bookingOverlay.job.endDate }}</span></div>
|
||
<div v-if="bookingOverlay.job?.assignedTech" class="sb-rp-field">
|
||
<span class="sb-rp-lbl">Date de fin</span>
|
||
<input type="date" class="sb-form-input" :value="bookingOverlay.job?.endDate || ''" @change="setEndDate(bookingOverlay.job, $event.target.value)" style="margin-top:2px" />
|
||
</div>
|
||
<div class="sb-rp-field"><span class="sb-rp-lbl">Statut</span>{{ bookingOverlay.job?.status }}</div>
|
||
<div class="sb-rp-field"><span class="sb-rp-lbl">Tags</span>
|
||
<TagEditor v-if="bookingOverlay.job" :model-value="bookingOverlay.job.tagsWithLevel || bookingOverlay.job.tags || []"
|
||
@update:model-value="v => { bookingOverlay.job.tagsWithLevel = v; bookingOverlay.job.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistJobTags(bookingOverlay.job) }"
|
||
:all-tags="store.allTags" :get-color="getTagColor" :show-required="true" :show-level="true"
|
||
level-label="Niveau min." level-hint="1 = basique · 5 = expert requis"
|
||
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
|
||
</div>
|
||
<div v-if="bookingOverlay.job?.assistants?.length" class="sb-rp-field">
|
||
<span class="sb-rp-lbl">Assistants</span>
|
||
<div v-for="a in bookingOverlay.job.assistants" :key="a.techId" style="display:flex;align-items:center;gap:6px;margin-top:3px">
|
||
<span class="sb-assist-badge" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===a.techId)?.colorIdx||0]">{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</span>
|
||
<span style="font-size:0.72rem">{{ a.techName }} · {{ fmtDur(a.duration) }}{{ a.note ? ' · '+a.note : '' }}</span>
|
||
<button style="margin-left:auto;background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem" @click="store.removeAssistant(bookingOverlay.job.id, a.techId); invalidateRoutes()">✕</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="sb-rp-actions">
|
||
<button class="sb-rp-primary" @click="openMoveModal(bookingOverlay.job, bookingOverlay.tech?.id)">↔ Déplacer / Réassigner</button>
|
||
<button class="sb-rp-btn" @click="startGeoFix(bookingOverlay.job)">📍 Géofixer sur la carte</button>
|
||
<button class="sb-rp-btn" @click="ctxMenu={job:bookingOverlay.job,techId:bookingOverlay.tech?.id};ctxUnschedule();bookingOverlay=null">✕ Désaffecter</button>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- Center column -->
|
||
<div class="sb-center-col">
|
||
<div class="sb-board" ref="boardScroll" @mousedown="startLasso($event)" @mousemove="moveLasso($event)" @mouseup="if(lasso) _lassoJustEnded = true; endLasso($event)">
|
||
<div v-if="lasso" class="sb-lasso" :style="lassoStyle"></div>
|
||
|
||
<!-- Week view -->
|
||
<WeekCalendar v-if="isCalView"
|
||
:filtered-resources="filteredResources" :day-columns="dayColumns"
|
||
:selected-tech-id="selectedTechId" :drop-ghost="dropGhost" :today-str="todayStr"
|
||
@go-to-day="goToDay" @select-tech="selectTechOnBoard" @ctx-tech="openTechCtx"
|
||
@tech-reorder-start="onTechReorderStart" @tech-reorder-drop="onTechReorderDrop"
|
||
@cal-drop="onCalDrop" @job-dragstart="onJobDragStart"
|
||
@job-click="selectJob" @job-dblclick="openEditModal" @job-ctx="openCtxMenu"
|
||
@clear-filters="clearFilters" />
|
||
|
||
<!-- Month view -->
|
||
<MonthCalendar v-else-if="currentView==='month'"
|
||
:anchor-date="anchorDate" :filtered-resources="filteredResources" :today-str="todayStr"
|
||
@go-to-day="goToDay" @select-tech="selectTechOnBoard" />
|
||
|
||
<!-- Day view -->
|
||
<div v-else class="sb-grid" :style="'min-width:'+(220+totalW)+'px'">
|
||
<div class="sb-grid-hdr">
|
||
<div class="sb-res-hdr">Ressources <span class="sbf-count">{{ filteredResources.length }}</span></div>
|
||
<div class="sb-time-hdr-wrap" :style="'width:'+totalW+'px;position:relative;height:100%'">
|
||
<div v-for="tick in hourTicks" :key="'dht-'+tick.x" class="sb-htick" :style="'left:'+tick.x+'px'">
|
||
<span v-if="tick.label" class="sb-htick-lbl">{{ tick.label }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="store.loading" class="sb-loading-row">Chargement…</div>
|
||
<div v-else-if="!filteredResources.length" class="sb-empty-row">
|
||
Aucune ressource. <button class="sbf-primary-btn" style="display:inline-block;margin-left:0.75rem" @click="clearFilters">Réinitialiser</button>
|
||
</div>
|
||
<TimelineRow v-for="tech in filteredResources" :key="tech.id"
|
||
:tech="tech" :segments="techDayJobsWithTravel(tech)"
|
||
:hour-ticks="hourTicks" :total-w="totalW" :px-per-hr="pxPerHr"
|
||
:h-start="H_START" :h-end="H_END" :row-h="ROW_H"
|
||
:is-selected="selectedTechId===tech.id"
|
||
:is-elevated="techHasLinkedJob(tech)||techIsHovered(tech)"
|
||
:drop-ghost-x="dropGhost?.techId===tech.id ? dropGhost.x : null"
|
||
@select-tech="selectTechOnBoard" @ctx-tech="openTechCtx" @open-tech-tags="t => techTagModal = t"
|
||
@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"
|
||
@job-dblclick="openEditModal" @job-ctx="openCtxMenu"
|
||
@assist-ctx="openAssistCtx"
|
||
@hover-job="id => hoveredJobId=id" @unhover-job="hoveredJobId=null"
|
||
@block-move="startBlockMove" @block-resize="startResize" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom panel -->
|
||
<BottomPanel :open="bottomPanelOpen" :height="bottomPanelH"
|
||
:groups="unassignedGrouped" :unscheduled-count="unscheduledJobs.length"
|
||
:selected="bottomSelected" :drop-active="unassignDropActive"
|
||
@update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize"
|
||
@toggle-select="toggleBottomSelect" @select-all="selectAllBottom" @clear-select="clearBottomSelect"
|
||
@lasso-select="ids => { const s = new Set(bottomSelected); ids.forEach(id => s.add(id)); bottomSelected = s }"
|
||
@deselect-all="() => { clearBottomSelect(); selectedJob = null; multiSelect = []; rightPanel = null }"
|
||
@batch-assign="batchAssignBottom" @auto-distribute="autoDistribute"
|
||
@open-criteria="dispatchCriteriaModal=true"
|
||
@row-click="(job, ev) => { if(ev?.shiftKey || ev?.ctrlKey || ev?.metaKey) { toggleBottomSelect(job.id, ev) } else { rightPanel={ mode:'details', data:{ job, tech:null } } } }"
|
||
@row-dblclick="openEditModal"
|
||
@row-dragstart="(e, job) => onJobDragStart(e, job, null)"
|
||
@drop-unassign="(e, type) => { if(type==='over') unassignDropActive=!!dragJob; else if(type==='leave') unassignDropActive=false; else onDropUnassign(e) }" />
|
||
</div>
|
||
|
||
<!-- Map panel -->
|
||
<div v-if="mapVisible" class="sb-map-backdrop" @click="mapVisible=false"></div>
|
||
<div v-if="mapVisible" class="sb-map-panel" @click.stop="()=>{}" :style="`width:${mapPanelW}px;min-width:${mapPanelW}px`">
|
||
<div class="sb-map-resize-handle" @mousedown.prevent="startMapResize"></div>
|
||
<div class="sb-map-bar" :class="{ 'sb-map-bar-geofix': geoFixJob }">
|
||
<span class="sb-map-title">Carte</span>
|
||
<template v-if="geoFixJob">
|
||
<span class="sb-geofix-hint">📍 Cliquer sur la carte pour placer <strong>{{ geoFixJob.subject }}</strong></span>
|
||
<button class="sb-geofix-cancel" @click="cancelGeoFix">✕ Annuler</button>
|
||
</template>
|
||
<template v-else>
|
||
<span v-if="selectedTechId" class="sb-map-tech" :style="'color:'+TECH_COLORS[store.technicians.find(t=>t.id===selectedTechId)?.colorIdx||0]">
|
||
● {{ store.technicians.find(t=>t.id===selectedTechId)?.fullName }}
|
||
<span class="sb-map-route-hint">· Glisser une job sur le trajet</span>
|
||
</span>
|
||
<span v-else class="sb-map-hint">Cliquer un technicien pour voir son trajet</span>
|
||
<button class="sb-map-close" @click="mapVisible=false">✕</button>
|
||
</template>
|
||
</div>
|
||
<div class="sb-map-legend">
|
||
<div v-for="(col, lbl) in SVC_COLORS" :key="lbl" class="sb-legend-item">
|
||
<span class="sb-legend-dot" :style="'background:'+col"></span>{{ lbl }}
|
||
</div>
|
||
</div>
|
||
<div ref="mapContainer" class="sb-map"></div>
|
||
</div>
|
||
|
||
<!-- Right panel -->
|
||
<RightPanel :panel="rightPanel"
|
||
@close="rightPanel=null; selectedJob=null"
|
||
@edit="openEditModal" @move="openMoveModal" @geofix="startGeoFix"
|
||
@unassign="job => { fullUnassign(job); rightPanel=null }"
|
||
@set-end-date="setEndDate"
|
||
@remove-assistant="(jobId, techId) => { store.removeAssistant(jobId, techId); invalidateRoutes() }"
|
||
@assign-pending="() => rightPanel=null"
|
||
@update-tags="(job, v) => { job.tags = v; persistJobTags(job) }" />
|
||
|
||
</div>
|
||
|
||
<!-- ══ CONTEXT MENUS ══ -->
|
||
<div v-if="ctxMenu" class="sb-ctx" :style="'left:'+ctxMenu.x+'px;top:'+ctxMenu.y+'px'" @click.stop="()=>{}">
|
||
<button class="sb-ctx-item" @click="ctxDetails()">📄 Voir détails</button>
|
||
<button class="sb-ctx-item" @click="ctxMove()">↔ Déplacer / Réassigner</button>
|
||
<button class="sb-ctx-item" @click="openTimeModal(ctxMenu.job, ctxMenu.techId); closeCtxMenu()">🕐 Fixer l'heure</button>
|
||
<button class="sb-ctx-item" @click="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button>
|
||
<div class="sb-ctx-sep"></div>
|
||
<button class="sb-ctx-item sb-ctx-warn" @click="ctxUnschedule()">✕ Désaffecter</button>
|
||
</div>
|
||
|
||
<div v-if="techCtx" class="sb-ctx" :style="'left:'+techCtx.x+'px;top:'+techCtx.y+'px'" @click.stop="()=>{}">
|
||
<button class="sb-ctx-item" @click="selectTechOnBoard(techCtx.tech); techCtx=null">🗺 Voir sur la carte</button>
|
||
<button class="sb-ctx-item" @click="optimizeRoute()">🔀 Optimiser la route</button>
|
||
<button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button>
|
||
<div class="sb-ctx-sep"></div>
|
||
<button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null">↗ Ouvrir dans ERPNext</button>
|
||
</div>
|
||
|
||
<div v-if="assistCtx" class="sb-ctx" :style="'left:'+assistCtx.x+'px;top:'+assistCtx.y+'px'" @click.stop="()=>{}">
|
||
<button class="sb-ctx-item" @click="assistCtxTogglePin()">
|
||
{{ assistCtx?.job?.assistants?.find(a=>a.techId===assistCtx?.techId)?.pinned ? '↕ Rendre flottant' : '📌 Prioriser dans le timeline' }}
|
||
</button>
|
||
<button class="sb-ctx-item" @click="assistCtxNote()">📝 Modifier la note</button>
|
||
<button class="sb-ctx-item" @click="bookingOverlay={job:assistCtx.job, tech:store.technicians.find(t=>t.id===assistCtx.job.assignedTech)}; assistCtx=null">📄 Voir le job parent</button>
|
||
<div class="sb-ctx-sep"></div>
|
||
<button class="sb-ctx-item sb-ctx-warn" @click="assistCtxRemove()">✕ Retirer l'assistant</button>
|
||
</div>
|
||
|
||
<!-- ══ MULTI-SELECT TOOLBAR ══ -->
|
||
<transition name="sb-slide-up">
|
||
<div v-if="multiSelect.length" class="sb-multi-bar">
|
||
<span class="sb-multi-count">{{ multiSelect.length }} sélectionné{{ multiSelect.length>1?'s':'' }}</span>
|
||
<button class="sb-multi-btn" @click="batchUnassign(pushUndo)">✕ Désaffecter</button>
|
||
<span class="sb-multi-sep">|</span>
|
||
<span class="sb-multi-lbl">Déplacer vers :</span>
|
||
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech" :style="'border-color:'+TECH_COLORS[t.colorIdx]" @click="batchMoveTo(t.id, localDateStr(periodStart), pushUndo)">
|
||
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase() }}
|
||
</button>
|
||
<button class="sb-multi-btn sb-multi-clear" @click="multiSelect=[]; selectedJob=null">Annuler</button>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- ══ MODALS ══ -->
|
||
<div v-if="techTagModal" class="sb-overlay" @click.self="techTagModal=null">
|
||
<div class="sb-modal">
|
||
<div class="sb-modal-hdr"><span>🏷 Tags — {{ techTagModal.fullName }}</span><button class="sb-rp-close" @click="techTagModal=null">✕</button></div>
|
||
<div class="sb-modal-body">
|
||
<TagEditor :model-value="techTagModal.tagsWithLevel || []" @update:model-value="v => { techTagModal.tagsWithLevel = v; techTagModal.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistTechTags(techTagModal) }"
|
||
:all-tags="store.allTags" :get-color="getTagColor" :show-level="true"
|
||
level-label="Compétence" level-hint="1 = base · 5 = expert"
|
||
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
|
||
</div>
|
||
<div class="sb-modal-ftr"><button class="sb-rp-primary" @click="techTagModal=null">Fermer</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="assistNoteModal" class="sb-overlay" @click.self="assistNoteModal=null">
|
||
<div class="sb-modal">
|
||
<div class="sb-modal-hdr"><span>📝 Note assistant</span><button class="sb-rp-close" @click="assistNoteModal=null">✕</button></div>
|
||
<div class="sb-modal-body">
|
||
<label class="sb-form-lbl">Titre / note pour cette tâche</label>
|
||
<input type="text" class="sb-form-input" v-model="assistNoteModal.note" placeholder="Ex: Livraison outil, Support câblage..." @keyup.enter="confirmAssistNote" />
|
||
<p style="font-size:0.6rem;color:#7b80a0;margin-top:0.3rem">Job parent : {{ assistNoteModal.job?.subject }}</p>
|
||
</div>
|
||
<div class="sb-modal-footer"><button class="sb-rp-primary" @click="confirmAssistNote">Enregistrer</button><button class="sb-rp-btn" @click="assistNoteModal=null">Annuler</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="timeModal" class="sb-overlay" @click.self="timeModal=null">
|
||
<div class="sb-modal">
|
||
<div class="sb-modal-hdr"><span>🕐 Heure de début fixe</span><button class="sb-rp-close" @click="timeModal=null">✕</button></div>
|
||
<div class="sb-modal-body">
|
||
<div class="sb-form-row"><label class="sb-form-lbl">Job</label><div class="sb-form-val">{{ timeModal.job?.subject }}</div></div>
|
||
<div class="sb-form-row"><label class="sb-form-lbl">Heure fixe</label><input type="time" class="sb-form-input" v-model="timeModal.time" /></div>
|
||
<div v-if="timeModal.hasPin" style="font-size:0.68rem;color:#f59e0b;margin-top:0.4rem">⚠ Heure actuellement fixée — modifier ou supprimer ci-dessous.</div>
|
||
</div>
|
||
<div class="sb-modal-ftr">
|
||
<button class="sbf-primary-btn" @click="confirmTime">📌 Fixer</button>
|
||
<button v-if="timeModal.hasPin" class="sb-rp-btn" style="color:#ef4444" @click="clearTime">✕ Supprimer</button>
|
||
<button class="sb-rp-btn" @click="timeModal=null">Annuler</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="moveModalOpen" class="sb-overlay" @click.self="moveModalOpen=false">
|
||
<div class="sb-modal">
|
||
<div class="sb-modal-hdr"><span>Déplacer la réservation</span><button class="sb-rp-close" @click="moveModalOpen=false">✕</button></div>
|
||
<div class="sb-modal-body" v-if="moveForm">
|
||
<div class="sb-form-row"><label class="sb-form-lbl">Ticket</label><div class="sb-form-val">{{ moveForm.job?.subject }}</div></div>
|
||
<div class="sb-form-row"><label class="sb-form-lbl">Technicien actuel</label><div class="sb-form-val">{{ store.technicians.find(t=>t.id===moveForm.srcTechId)?.fullName || '—' }}</div></div>
|
||
<div class="sb-form-row"><label class="sb-form-lbl">Nouveau technicien</label>
|
||
<select class="sb-form-sel" v-model="moveForm.newTechId"><option v-for="t in store.technicians" :key="t.id" :value="t.id">{{ t.fullName }}</option></select>
|
||
</div>
|
||
<div class="sb-form-row"><label class="sb-form-lbl">Nouvelle date</label><input type="date" class="sb-form-input" v-model="moveForm.newDate" /></div>
|
||
</div>
|
||
<div class="sb-modal-ftr"><button class="sbf-primary-btn" @click="confirmMove">✓ Confirmer</button><button class="sb-rp-btn" @click="moveModalOpen=false">Annuler</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="resSelectorOpen" class="sb-overlay" @click.self="resSelectorOpen=false">
|
||
<div class="sb-modal sb-modal-wide">
|
||
<div class="sb-modal-hdr"><span>Sélectionner les ressources à afficher</span><button class="sb-rp-close" @click="resSelectorOpen=false">✕</button></div>
|
||
<div class="sb-modal-body">
|
||
<div class="sb-res-sel-wrap">
|
||
<div class="sb-res-sel-col">
|
||
<div class="sb-res-sel-hdr">Disponibles</div>
|
||
<div v-for="t in store.technicians.filter(x=>!tempSelectedIds.includes(x.id))" :key="t.id" class="sb-res-sel-item" @click="toggleTempRes(t.id)">
|
||
<div class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>{{ t.fullName }}
|
||
</div>
|
||
<div v-if="!store.technicians.filter(x=>!tempSelectedIds.includes(x.id)).length" class="sbf-empty">Toutes sélectionnées</div>
|
||
</div>
|
||
<div class="sb-res-sel-arrow">→</div>
|
||
<div class="sb-res-sel-col">
|
||
<div class="sb-res-sel-hdr">Sélectionnées <span class="sbf-count">{{ tempSelectedIds.length || 'Toutes' }}</span></div>
|
||
<div v-for="t in store.technicians.filter(x=>tempSelectedIds.includes(x.id))" :key="t.id" class="sb-res-sel-item sb-res-sel-active" @click="toggleTempRes(t.id)">
|
||
<div class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>{{ t.fullName }}<span class="sb-res-sel-rm">✕</span>
|
||
</div>
|
||
<div v-if="!tempSelectedIds.length" class="sbf-empty">Toutes affichées</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="sb-modal-ftr"><button class="sbf-primary-btn" @click="applyResSelector">Appliquer</button><button class="sb-rp-btn" @click="resSelectorOpen=false">Annuler</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<WoCreateModal v-model="woModal" @confirm="confirmWo" />
|
||
<JobEditModal v-model="editModal" @confirm="confirmEdit" />
|
||
|
||
<div v-if="dispatchCriteriaModal" class="sb-overlay" @click.self="dispatchCriteriaModal=false">
|
||
<div class="sb-modal">
|
||
<div class="sb-modal-hdr"><span>⚙ Critères de dispatch automatique</span><button class="sb-rp-close" @click="dispatchCriteriaModal=false">✕</button></div>
|
||
<div class="sb-modal-body">
|
||
<p style="font-size:0.65rem;color:var(--sb-muted);margin:0 0 0.5rem">Glissez pour réordonner. Les critères du haut ont plus de poids.</p>
|
||
<div v-for="(c, i) in dispatchCriteria" :key="c.id" class="sb-crit-row"
|
||
draggable="true"
|
||
:class="{ 'sb-crit-drag-over': critDragOver === i }"
|
||
@dragstart="critDragIdx = i; $event.dataTransfer.effectAllowed = 'move'"
|
||
@dragend="critDragIdx = null; critDragOver = null"
|
||
@dragover.prevent="critDragOver = i"
|
||
@dragleave="critDragOver === i && (critDragOver = null)"
|
||
@drop.prevent="dropCriterion(i)">
|
||
<span class="sb-crit-handle" title="Glisser">⠿</span>
|
||
<span class="sb-crit-order">{{ i + 1 }}</span>
|
||
<label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label>
|
||
<div class="sb-crit-arrows">
|
||
<button :disabled="i===0" @click="moveCriterion(i,-1)">▲</button>
|
||
<button :disabled="i===dispatchCriteria.length-1" @click="moveCriterion(i,1)">▼</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="sb-modal-ftr"><button class="sbf-primary-btn" @click="saveDispatchCriteria">✓ Enregistrer</button><button class="sb-rp-btn" @click="dispatchCriteriaModal=false">Annuler</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ GPS Settings Modal ══ -->
|
||
<div v-if="gpsSettingsOpen" class="sb-modal-overlay" @click.self="gpsSettingsOpen=false">
|
||
<div class="sb-gps-modal">
|
||
<div class="sb-gps-modal-hdr">
|
||
<h3>📡 GPS Tracking — Traccar</h3>
|
||
<button class="sb-gps-close" @click="gpsSettingsOpen=false">×</button>
|
||
</div>
|
||
<div class="sb-gps-modal-body">
|
||
<p class="sb-gps-desc">Gérer les techniciens, associer les devices Traccar et suivre le GPS en temps réel.</p>
|
||
<table class="sb-gps-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Technicien</th>
|
||
<th>Téléphone</th>
|
||
<th>Statut</th>
|
||
<th>Device GPS</th>
|
||
<th>GPS</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="tech in store.technicians" :key="tech.id">
|
||
<td>
|
||
<input v-if="editingTech === tech.id" class="sb-gps-input sb-gps-edit-name" :value="tech.fullName"
|
||
@blur="saveTechField(tech, 'full_name', $event.target.value); editingTech = null"
|
||
@keydown.enter="$event.target.blur()" @keydown.escape="editingTech = null" />
|
||
<strong v-else @dblclick="editingTech = tech.id" class="sb-gps-editable" title="Double-clic pour modifier">{{ tech.fullName }}</strong>
|
||
</td>
|
||
<td>
|
||
<input class="sb-gps-input sb-gps-phone" :value="tech.phone" placeholder="514..."
|
||
@blur="saveTechField(tech, 'phone', $event.target.value)"
|
||
@keydown.enter="$event.target.blur()" />
|
||
</td>
|
||
<td>
|
||
<select :value="tech.status || 'available'" @change="saveTechField(tech, 'status', $event.target.value)" class="sb-gps-select sb-gps-status-sel">
|
||
<option value="available">Disponible</option>
|
||
<option value="busy">Occupé</option>
|
||
<option value="off">Absent</option>
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<select :value="tech.traccarDeviceId || ''" @change="saveTraccarLink(tech, $event.target.value)" class="sb-gps-select">
|
||
<option value="">— Non lié —</option>
|
||
<option v-for="d in store.traccarDevices" :key="d.id" :value="String(d.id)">
|
||
{{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }}
|
||
</option>
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<span v-if="tech.gpsCoords" class="sb-gps-badge sb-gps-online" :title="tech.gpsCoords[1].toFixed(4)+', '+tech.gpsCoords[0].toFixed(4)">
|
||
En ligne<span v-if="tech.gpsSpeed > 1"> · {{ (tech.gpsSpeed * 1.852).toFixed(0) }}km/h</span>
|
||
</span>
|
||
<span v-else-if="tech.traccarDeviceId" class="sb-gps-badge sb-gps-offline">Hors ligne</span>
|
||
<span v-else class="sb-gps-badge sb-gps-none">—</span>
|
||
</td>
|
||
<td><button class="sb-gps-del-btn" @click="removeTech(tech)" title="Supprimer">×</button></td>
|
||
</tr>
|
||
<!-- Add technician row -->
|
||
<tr class="sb-gps-add-row">
|
||
<td><input v-model="newTechName" class="sb-gps-input" placeholder="Nom complet" @keydown.enter="addTech" /></td>
|
||
<td><input v-model="newTechPhone" class="sb-gps-input sb-gps-phone" placeholder="514..." /></td>
|
||
<td></td>
|
||
<td>
|
||
<select v-model="newTechDevice" class="sb-gps-select">
|
||
<option value="">— Aucun —</option>
|
||
<option v-for="d in store.traccarDevices" :key="d.id" :value="String(d.id)">
|
||
{{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }}
|
||
</option>
|
||
</select>
|
||
</td>
|
||
<td colspan="2">
|
||
<button class="sb-gps-add-btn" @click="addTech" :disabled="!newTechName.trim() || addingTech">
|
||
{{ addingTech ? '...' : '+ Ajouter' }}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="sb-gps-footer">
|
||
<span class="sb-gps-info">{{ store.traccarDevices.length }} devices Traccar · {{ store.technicians.length }} techniciens</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</template>
|
||
|
||
<style src="./dispatch-styles.scss"></style>
|