gigafibre-fsm/apps/ops/src/pages/DispatchPage.vue
louispaulb 91da480aeb perf: memoize dispatch timeline segments + load/capacity as computed Maps
Before: techDayJobsWithTravel(tech), periodLoadH(tech), techPeriodCapacityH(tech)
were called as functions in the template v-for — recalculated on EVERY render
for every tech (10 techs × 3 functions = 30 expensive recomputations per render).

After: Pre-computed as Vue computed Maps (segmentsMap, loadMap, capMap) that
only recompute when their reactive dependencies actually change. Template
reads from map[tech.id] — instant O(1) lookup, no recalculation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 18:24:16 -04:00

1235 lines
68 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 { ROW_H, RES_ICONS, HUB_SSE_URL } from 'src/config/dispatch'
import { fetchOpenRequests } from 'src/api/service-request'
import { updateJob, updateTech } from 'src/api/dispatch'
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 UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import PublishScheduleModal from 'src/modules/dispatch/components/PublishScheduleModal.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'
import SbModal from 'src/modules/dispatch/components/SbModal.vue'
import SbContextMenu from 'src/modules/dispatch/components/SbContextMenu.vue'
import {
localDateStr, timeToH, hToTime, fmtDur,
SVC_COLORS, prioLabel, prioClass, serializeAssistants,
jobColor as _jobColorBase, ICON, prioColor,
WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS,
} 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'
import { useAddressSearch } from 'src/composables/useAddressSearch'
import { useAbsenceResize } from 'src/composables/useAbsenceResize'
const store = useDispatchStore()
const auth = useAuthStore()
const erpUrl = BASE_URL || window.location.origin
const {
currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr,
prevPeriod, nextPeriod, goToToday, goToDay,
} = usePeriodNavigation()
const {
selectedResIds, filterStatus, filterTags, filterResourceType, searchQuery, techSort, manualOrder,
filteredResources, groupedResources, availableGroups, filterGroup,
showInactive, inactiveCount, humanCount, materialCount, availableCategories,
resSelectorOpen, tempSelectedIds, dragReorderTech,
openResSelector, applyResSelector, toggleTempRes, clearFilters,
onTechReorderStart, onTechReorderDrop,
} = useResourceFilter(store)
const techTagModal = ref(null)
const {
getTagColor, onCreateTag, onUpdateTag, onRenameTag, onDeleteTag,
_serializeTags, persistJobTags, persistTechTags,
} = useTagManagement(store)
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
const setEndDate = (job, endDate) => {
job.endDate = endDate || null
updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {})
}
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'))
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()
}
const getJobDate = jobId => store.jobs.find(j => j.id === jobId)?.scheduledDate || null
const getJobTime = jobId => 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)
const openTimeModal = (job, techId) => { timeModal.value = { job, techId, time: getJobTime(job.id) || '08:00', hasPin: !!getJobTime(job.id) } }
const confirmTime = () => { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, timeModal.value.time); timeModal.value = null }
const clearTime = () => { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, null); timeModal.value = null }
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))
const jobColor = job => _jobColorBase(job, TECH_COLORS, store)
const PX_PER_HR = ref(80)
const pxPerHr = computed(() => currentView.value === 'week' ? PX_PER_HR.value * 0.55 : currentView.value === 'month' ? 0 : PX_PER_HR.value)
const dayW = computed(() => currentView.value === 'month' ? 110 : (H_END - H_START) * pxPerHr.value)
const totalW = computed(() => dayW.value * periodDays.value)
const {
H_START, H_END, routeLegs, routeGeometry,
techAllJobsForDate, techDayJobsWithTravel,
periodLoadH, techPeriodCapacityH, techDayEndH,
} = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor)
const { startAbsenceResize } = useAbsenceResize(pxPerHr, H_START)
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)
// Pre-compute expensive per-tech data (memoized — avoids recalculating on every render)
const segmentsMap = computed(() => {
const map = {}
for (const tech of filteredResources.value) map[tech.id] = techDayJobsWithTravel(tech)
return map
})
const loadMap = computed(() => {
const map = {}
for (const tech of filteredResources.value) map[tech.id] = periodLoadH(tech)
return map
})
const capMap = computed(() => {
const map = {}
for (const tech of filteredResources.value) map[tech.id] = techPeriodCapacityH(tech)
return map
})
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
const 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()
}
const {
ctxMenu, techCtx, assistCtx, assistNoteModal,
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
assistCtxTogglePin, assistCtxRemove, assistCtxNote, confirmAssistNote,
ctxDetails, ctxMove, ctxUnschedule,
} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal })
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 })
const {
hoveredJobId, selectedJob, multiSelect,
selectJob: _selectJob, isJobMultiSelected, batchUnassign, batchMoveTo,
lasso, boardScroll, lassoStyle, startLasso, moveLasso, endLasso,
techHasLinkedJob, techIsHovered,
} = useSelection({ store, periodStart, smartAssign, invalidateRoutes, fullUnassign })
const selectJob = (job, techId, isAssist = false, assistTechId = null, event = null) => _selectJob(job, techId, isAssist, assistTechId, event, rightPanel)
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,
})
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
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() }
},
)
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()
}
const bookingOverlay = ref(null)
const woModalOpen = ref(false)
const woModalCtx = ref({})
const publishModalOpen = ref(false)
const draftCount = computed(() => store.jobs.filter(j => !j.published && j.status !== 'completed' && j.status !== 'cancelled').length)
const periodEndStr = computed(() => {
const ps = periodStart.value
if (!ps) return ''
const d = new Date(ps + 'T12:00:00')
d.setDate(d.getDate() + (periodDays.value || 7) - 1)
return d.toISOString().slice(0, 10)
})
const onPublished = jobNames => store.publishJobsLocal(jobNames)
const gpsSettingsOpen = ref(false)
const gpsShowInactive = ref(false)
const gpsFilteredTechs = computed(() =>
gpsShowInactive.value ? store.technicians : store.technicians.filter(t => t.active !== false)
)
const {
editingTech, newTechName, newTechPhone, newTechDevice, addingTech,
absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing,
saveTechField, addTech,
openAbsenceModal, confirmAbsence, endAbsence,
deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule,
ABSENCE_REASONS,
} = useTechManagement(store, invalidateRoutes)
const newTechGroup = ref('')
const scheduleModalTech = ref(null)
const scheduleForm = ref({})
function openScheduleModal (tech) {
scheduleModalTech.value = tech
scheduleForm.value = {}
WEEK_DAYS.forEach(d => {
const day = tech.weeklySchedule?.[d]
scheduleForm.value[d] = day ? { on: true, start: day.start || '08:00', end: day.end || '16:00' } : { on: false, start: '08:00', end: '16:00' }
})
}
const applySchedulePreset = preset => {
WEEK_DAYS.forEach(d => {
const day = preset.schedule[d]
scheduleForm.value[d] = day ? { on: true, start: day.start, end: day.end } : { on: false, start: '08:00', end: '16:00' }
})
}
function confirmSchedule () {
const sched = {}
WEEK_DAYS.forEach(d => {
const f = scheduleForm.value[d]
sched[d] = f.on ? { start: f.start, end: f.end } : null
})
saveWeeklySchedule(scheduleModalTech.value, sched)
scheduleModalTech.value = null
}
const resSelectorGroupFilter = ref('')
const resSelectorSearch = ref('')
const savedPresets = ref(JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]'))
const presetNameInput = ref('')
const showPresetSave = ref(false)
function savePreset () {
const name = presetNameInput.value.trim()
if (!name || !tempSelectedIds.value.length) return
const existing = savedPresets.value.findIndex(p => p.name === name)
const preset = { name, ids: [...tempSelectedIds.value], created: new Date().toISOString() }
if (existing >= 0) savedPresets.value.splice(existing, 1, preset)
else savedPresets.value.push(preset)
localStorage.setItem('sbv2-resPresets', JSON.stringify(savedPresets.value))
presetNameInput.value = ''
showPresetSave.value = false
}
const loadPreset = preset => { tempSelectedIds.value = [...preset.ids] }
function deletePreset (idx) {
savedPresets.value.splice(idx, 1)
localStorage.setItem('sbv2-resPresets', JSON.stringify(savedPresets.value))
}
const activePresetName = computed(() => {
if (!selectedResIds.value.length) return null
const ids = selectedResIds.value
return savedPresets.value.find(p => p.ids.length === ids.length && p.ids.every(id => ids.includes(id)))?.name || null
})
const resSelectorGroupsFiltered = computed(() => {
const grouper = (techs) => {
const groups = new Map()
for (const t of techs) {
const g = t.group || ''
if (!groups.has(g)) groups.set(g, [])
groups.get(g).push(t)
}
const sorted = [...groups.entries()].sort((a, b) => {
if (!a[0] && b[0]) return 1
if (a[0] && !b[0]) return -1
return a[0].localeCompare(b[0])
})
return sorted.map(([name, techs]) => ({ name, label: name || 'Sans groupe', techs }))
}
let techs = store.technicians.filter(t => t.status !== 'inactive')
if (resSelectorGroupFilter.value) techs = techs.filter(t => t.group === resSelectorGroupFilter.value)
if (resSelectorSearch.value) {
const q = resSelectorSearch.value.toLowerCase()
techs = techs.filter(t => t.fullName.toLowerCase().includes(q))
}
return {
available: grouper(techs.filter(x => !tempSelectedIds.value.includes(x.id))),
selected: grouper(store.technicians.filter(x => tempSelectedIds.value.includes(x.id))),
}
})
function resIcon (t) {
if (t.resourceType !== 'material') return ''
return RES_ICONS[t.resourceCategory] || RES_ICONS[t.fullName] || '🔧'
}
function openResSelectorFull () {
resSelectorGroupFilter.value = filterGroup.value
resSelectorSearch.value = ''
openResSelector()
}
const applyGroupFilter = () => { filterGroup.value = resSelectorGroupFilter.value; resSelectorOpen.value = false }
async function onTechStatusChange (tech, value) {
tech.status = value
tech.active = value !== 'inactive'
saveTechField(tech, 'status', value)
}
async function saveTechGroup (tech, value) {
const trimmed = (value || '').trim()
if (trimmed === tech.group) return
tech.group = trimmed
try { await import('src/api/dispatch').then(m => m.updateTech(tech.name || tech.id, { tech_group: trimmed })) }
catch {}
}
function openWoModal (prefillDate = null, prefillTech = null) {
woModalCtx.value = { scheduled_date: prefillDate || todayStr, assigned_tech: prefillTech || null }
woModalOpen.value = true
}
async function confirmWo (formData) {
return await store.createJob({
subject: formData.subject,
address: formData.address,
duration_h: formData.duration_h,
priority: formData.priority,
assigned_tech: formData.assigned_tech || null,
scheduled_date: formData.scheduled_date || null,
latitude: formData._latitude || null,
longitude: formData._longitude || null,
note: formData.description || '',
tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t),
depends_on: formData.depends_on || '',
})
}
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)
}
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
}
const boardTabs = ref(['Vue principale','Par région'])
const activeTab = ref('Vue principale')
function onDropUnassign (e) {
e.preventDefault()
if (dragJob.value) { fullUnassign(dragJob.value); dragJob.value = null; dragSrc.value = null }
unassignDropActive.value = false
}
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
}
}
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; woModalOpen.value = false; 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()
}
}
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('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', (tech) => loadMap.value[tech.id] ?? 0)
provide('techPeriodCapacityH', (tech) => capMap.value[tech.id] ?? 8)
provide('techDayEndH', techDayEndH)
provide('isJobMultiSelected', isJobMultiSelected)
provide('btColW', btColW)
provide('startColResize', startColResize)
provide('searchAddr', searchAddr)
provide('addrResults', addrResults)
provide('selectAddr', selectAddr)
let dispatchSse = null
function connectDispatchSSE () {
if (dispatchSse) dispatchSse.close()
dispatchSse = new EventSource(`${HUB_SSE_URL}/sse?topics=dispatch`)
dispatchSse.addEventListener('tech-absence', (e) => {
try {
const data = JSON.parse(e.data)
const tech = store.technicians.find(t => t.name === data.techName || t.id === data.techName)
if (!tech) return
if (data.action === 'set') {
tech.status = 'off'
tech.absenceFrom = data.from || null
tech.absenceUntil = data.until || null
tech.absenceStartTime = data.startTime || null
tech.absenceEndTime = data.endTime || null
tech.absenceReason = data.reason || 'personal'
} else if (data.action === 'clear') {
tech.status = 'available'
tech.absenceFrom = null
tech.absenceUntil = null
tech.absenceStartTime = null
tech.absenceEndTime = null
tech.absenceReason = ''
}
} catch {}
})
}
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()
connectDispatchSSE()
})
onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('click', closeCtxMenu); destroyMap(); store.stopGpsTracking(); if (dispatchSse) dispatchSse.close() })
</script>
<template>
<div class="sb-root" @click="onRootClick">
<header class="sb-header">
<div class="sb-header-left">
<div class="sb-search-bar" @click="openResSelectorFull">
<span class="sb-search-icon">🔍</span>
<span v-if="filterGroup" class="sb-search-chip" @click.stop="filterGroup=''">{{ filterGroup }} ✕</span>
<span v-if="activePresetName" class="sb-search-chip" @click.stop="selectedResIds=[]">{{ activePresetName }} ✕</span>
<span v-else-if="selectedResIds.length" class="sb-search-chip sb-search-chip-count" @click.stop="selectedResIds=[]">{{ selectedResIds.length }} ressource{{ selectedResIds.length>1?'s':'' }} ✕</span>
<span v-if="!filterGroup && !selectedResIds.length" class="sb-search-placeholder">Ressources…</span>
</div>
<div v-if="materialCount > 0" class="sb-res-type-toggle">
<button :class="{ active: !filterResourceType }" @click="filterResourceType=''">Tous <span class="sbf-count">{{ humanCount + materialCount }}</span></button>
<button :class="{ active: filterResourceType==='human' }" @click="filterResourceType='human'">👤 <span class="sbf-count">{{ humanCount }}</span></button>
<button :class="{ active: filterResourceType==='material' }" @click="filterResourceType='material'">🔧 <span class="sbf-count">{{ materialCount }}</span></button>
</div>
<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||filterGroup||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 v-for="v in [['day','Jour'],['week','Semaine'],['month','Mois']]" :key="v[0]" :class="{ active: currentView===v[0] }" @click="currentView=v[0]">{{ v[1] }}</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" style="background:#7c3aed" @click="publishModalOpen=true" title="Publier & envoyer l'horaire">
Publier <span v-if="draftCount" class="sbs-count" style="position:relative;top:-2px;right:auto;background:#ef4444">{{ draftCount }}</span>
</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>
<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="openResSelectorFull">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">Groupe</label>
<select class="sbf-select" v-model="filterGroup">
<option value="">Tous les groupes</option>
<option v-for="g in availableGroups" :key="g" :value="g">{{ g }}</option>
</select>
<label class="sbf-lbl">Statut</label>
<select class="sbf-select" v-model="filterStatus">
<option value="">Tous (actifs)</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><option value="inactive">Inactifs</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="min-width:120px">
<div class="sbf-title">Légende</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
<span style="display:inline-block;width:18px;height:12px;border-radius:3px;background:#6366f1"></span>
<span style="font-size:0.7rem;color:#c4c8e4">Publié</span>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="display:inline-block;width:18px;height:12px;border-radius:3px;border:1px dashed rgba(255,255,255,0.3);background:repeating-linear-gradient(-45deg,transparent,transparent 3px,rgba(255,255,255,0.08) 3px,rgba(255,255,255,0.08) 6px),#6366f1"></span>
<span style="font-size:0.7rem;color:#c4c8e4">Brouillon</span>
</div>
</div>
<div style="display:flex;align-items:flex-end;margin-left:auto">
<button v-if="filterStatus||filterGroup||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>
<div class="sb-body">
<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 v-for="f in [['Titre',bookingOverlay.job?.subject],['Adresse',bookingOverlay.job?.address],['Durée',fmtDur(bookingOverlay.job?.duration)],['Technicien',bookingOverlay.tech?.fullName||'—'],['Statut',bookingOverlay.job?.status]]" :key="f[0]" class="sb-rp-field">
<span class="sb-rp-lbl">{{ f[0] }}</span>{{ f[1] }}
</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">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">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>
<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>
<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"
@open-absence="openAbsenceModal" @end-absence="endAbsence"
@open-schedule="openScheduleModal" />
<MonthCalendar v-else-if="currentView==='month'"
:anchor-date="anchorDate" :filtered-resources="filteredResources" :today-str="todayStr"
@go-to-day="goToDay" @select-tech="selectTechOnBoard" />
<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="segmentsMap[tech.id] || []"
: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"
@absence-resize="startAbsenceResize"
@open-absence="openAbsenceModal" @end-absence="endAbsence"
@open-schedule="openScheduleModal" />
</div>
</div>
<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>
<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>
<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 -->
<SbContextMenu :pos="ctxMenu">
<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>
</SbContextMenu>
<SbContextMenu :pos="techCtx">
<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>
</SbContextMenu>
<SbContextMenu :pos="assistCtx">
<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>
</SbContextMenu>
<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>
<!-- Tech tags modal -->
<SbModal :model-value="!!techTagModal" @update:model-value="v => { if(!v) techTagModal=null }" modal-class="sb-modal-tags" body-style="overflow:visible;min-height:320px">
<template #header><span>🏷 Tags — {{ techTagModal?.fullName }}</span></template>
<TagEditor v-if="techTagModal" :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" />
<template #footer><button class="sb-rp-primary" @click="techTagModal=null">Fermer</button></template>
</SbModal>
<!-- Assistant note modal -->
<SbModal :model-value="!!assistNoteModal" @update:model-value="v => { if(!v) assistNoteModal=null }">
<template #header><span>📝 Note assistant</span></template>
<template v-if="assistNoteModal">
<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>
</template>
<template #footer><button class="sb-rp-primary" @click="confirmAssistNote">Enregistrer</button><button class="sb-rp-btn" @click="assistNoteModal=null">Annuler</button></template>
</SbModal>
<!-- Time pin modal -->
<SbModal :model-value="!!timeModal" @update:model-value="v => { if(!v) timeModal=null }">
<template #header><span>🕐 Heure de début fixe</span></template>
<template v-if="timeModal">
<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>
</template>
<template #footer>
<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>
</template>
</SbModal>
<!-- Move modal -->
<SbModal v-model="moveModalOpen">
<template #header><span>Déplacer la réservation</span></template>
<template 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>
</template>
<template #footer><button class="sbf-primary-btn" @click="confirmMove">✓ Confirmer</button><button class="sb-rp-btn" @click="moveModalOpen=false">Annuler</button></template>
</SbModal>
<!-- Resource selector modal -->
<SbModal :model-value="resSelectorOpen" @update:model-value="v => resSelectorOpen=v" :wide="true">
<template #header><span>Ressources & Groupes</span></template>
<div v-if="savedPresets.length" class="sb-rsel-groups">
<div class="sb-rsel-section-title">Sélections sauvegardées</div>
<div class="sb-rsel-chips">
<button v-for="(p, idx) in savedPresets" :key="p.name" class="sb-rsel-chip sb-rsel-preset"
:class="{ active: tempSelectedIds.length && p.ids.length === tempSelectedIds.length && p.ids.every(id => tempSelectedIds.includes(id)) }"
@click="loadPreset(p)">
{{ p.name }} <span class="sb-rsel-preset-count">{{ p.ids.length }}</span>
<span class="sb-rsel-preset-del" @click.stop="deletePreset(idx)" title="Supprimer">✕</span>
</button>
</div>
</div>
<div class="sb-rsel-groups">
<div class="sb-rsel-section-title">Groupes</div>
<div class="sb-rsel-chips">
<button class="sb-rsel-chip" :class="{ active: !resSelectorGroupFilter }" @click="resSelectorGroupFilter=''">Tous</button>
<button v-for="g in availableGroups" :key="g" class="sb-rsel-chip"
:class="{ active: resSelectorGroupFilter === g }" @click="resSelectorGroupFilter = resSelectorGroupFilter === g ? '' : g">{{ g }}</button>
</div>
<div class="sb-rsel-group-actions">
<button v-if="resSelectorGroupFilter" class="sb-rsel-apply-group" @click="applyGroupFilter">
Afficher seulement « {{ resSelectorGroupFilter }} »
</button>
</div>
</div>
<div class="sb-rsel-search-row">
<input v-model="resSelectorSearch" class="sb-rsel-search" placeholder="🔍 Rechercher une ressource…" />
</div>
<div class="sb-res-sel-wrap">
<div class="sb-res-sel-col">
<div class="sb-res-sel-hdr">Disponibles</div>
<template v-for="group in resSelectorGroupsFiltered.available" :key="'avail-'+group.name">
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }} <span class="sbf-count">{{ group.techs.length }}</span></div>
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item" @click="toggleTempRes(t.id)">
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
<span class="sb-res-sel-name">{{ t.fullName }}</span>
<span v-if="t.resourceCategory" class="sb-res-sel-cat-tag">{{ t.resourceCategory }}</span>
<span v-else-if="t.group" class="sb-res-sel-grp-tag">{{ t.group }}</span>
</div>
</template>
<div v-if="!resSelectorGroupsFiltered.available.flatMap(g=>g.techs).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>
<template v-for="group in resSelectorGroupsFiltered.selected" :key="'sel-'+group.name">
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }}</div>
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item sb-res-sel-active" @click="toggleTempRes(t.id)">
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
<span class="sb-res-sel-name">{{ t.fullName }}</span><span class="sb-res-sel-rm">✕</span>
</div>
</template>
<div v-if="!tempSelectedIds.length" class="sbf-empty">Toutes affichées</div>
</div>
</div>
<template #footer>
<button class="sbf-primary-btn" @click="applyResSelector">Appliquer</button>
<template v-if="tempSelectedIds.length">
<button v-if="!showPresetSave" class="sb-rp-btn" @click="showPresetSave=true">💾 Sauvegarder</button>
<div v-else class="sb-rsel-save-row">
<input v-model="presetNameInput" class="sb-rsel-save-input" placeholder="Nom du groupe…"
@keyup.enter="savePreset" @keyup.escape="showPresetSave=false" />
<button class="sb-rsel-save-btn" @click="savePreset" :disabled="!presetNameInput.trim()">✓</button>
<button class="sb-rp-btn" style="padding:0.2rem 0.4rem" @click="showPresetSave=false">✕</button>
</div>
<button class="sb-rp-btn" @click="tempSelectedIds=[]">Tout désélectionner</button>
</template>
<button class="sb-rp-btn" @click="resSelectorOpen=false">Annuler</button>
</template>
</SbModal>
<UnifiedCreateModal v-model="woModalOpen" mode="work-order"
:context="woModalCtx"
:technicians="store.technicians"
:external-tags="store.allTags"
:external-get-color="getTagColor"
:submit-handler="confirmWo" />
<JobEditModal v-model="editModal" @confirm="confirmEdit" />
<PublishScheduleModal v-model="publishModalOpen"
:jobs="store.jobs" :technicians="filteredResources"
:period-start="periodStart" :period-end="periodEndStr"
@published="onPublished" />
<!-- Dispatch criteria modal -->
<SbModal :model-value="dispatchCriteriaModal" @update:model-value="v => dispatchCriteriaModal=v">
<template #header><span>⚙ Critères de dispatch automatique</span></template>
<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>
<template #footer><button class="sbf-primary-btn" @click="saveDispatchCriteria">✓ Enregistrer</button><button class="sb-rp-btn" @click="dispatchCriteriaModal=false">Annuler</button></template>
</SbModal>
<!-- GPS settings modal (custom structure, not using SbModal) -->
<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">&times;</button>
</div>
<div class="sb-gps-modal-body">
<p class="sb-gps-desc">Gérer les ressources dispatch, associer les devices Traccar et suivre le GPS en temps réel.</p>
<div class="sb-gps-toggle-row">
<label class="sb-gps-toggle-label">
<input type="checkbox" v-model="gpsShowInactive" />
Afficher les inactifs
<span v-if="inactiveCount" class="sb-gps-inactive-count">({{ inactiveCount }})</span>
</label>
</div>
<table class="sb-gps-table">
<thead><tr><th>Technicien</th><th>Groupe</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 gpsFilteredTechs" :key="tech.id" :class="{ 'sb-gps-inactive-row': !tech.active }">
<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" :value="tech.group" placeholder="Ex: Terrain, Support..." style="width:100px"
@blur="saveTechGroup(tech, $event.target.value)" @keydown.enter="$event.target.blur()" list="tech-groups" />
</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="onTechStatusChange(tech, $event.target.value)" class="sb-gps-select sb-gps-status-sel">
<option v-for="s in [['available','Disponible'],['busy','Occupé'],['off','Absent'],['inactive','Inactif']]" :key="s[0]" :value="s[0]">{{ s[1] }}</option>
</select>
<div v-if="tech.absenceReason && !tech.active" class="sb-gps-absence-info">
{{ ABSENCE_REASONS.find(r => r.value === tech.absenceReason)?.icon || '📋' }}
{{ ABSENCE_REASONS.find(r => r.value === tech.absenceReason)?.label || tech.absenceReason }}
<span v-if="tech.absenceUntil" class="sb-gps-absence-date">→ {{ tech.absenceUntil }}</span>
</div>
</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 class="sb-gps-actions">
<button v-if="tech.active" class="sb-gps-absence-btn" @click="openAbsenceModal(tech)" title="Mettre en absence">⏸</button>
<button v-else class="sb-gps-react-btn" @click="endAbsence(tech)" title="Réactiver">▶</button>
</td>
</tr>
<tr class="sb-gps-add-row">
<td><input v-model="newTechName" class="sb-gps-input" placeholder="Nom complet" @keydown.enter="addTech({ tech_group: newTechGroup.trim() || '' }); newTechGroup = ''" /></td>
<td><input v-model="newTechGroup" class="sb-gps-input" placeholder="Groupe..." list="tech-groups" style="width:100px" /></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({ tech_group: newTechGroup.trim() || '' }); newTechGroup = ''" :disabled="!newTechName.trim() || addingTech">{{ addingTech ? '...' : '+ Ajouter' }}</button>
</td>
</tr>
</tbody>
</table>
<datalist id="tech-groups">
<option v-for="g in availableGroups" :key="g" :value="g" />
</datalist>
<div class="sb-gps-footer">
<span class="sb-gps-info">{{ store.traccarDevices.length }} devices Traccar · {{ store.technicians.length }} techniciens · {{ availableGroups.length }} groupes</span>
</div>
</div>
</div>
</div>
<!-- Absence modal -->
<SbModal :model-value="absenceModalOpen" @update:model-value="v => absenceModalOpen=v" overlay-class="sb-overlay-top" modal-class="sb-absence-modal">
<template #header><span>⏸ Mettre en absence — {{ absenceModalTech?.fullName }}</span></template>
<div class="sb-absence-form">
<label class="sb-absence-lbl">Raison</label>
<div class="sb-absence-reasons">
<button v-for="r in ABSENCE_REASONS" :key="r.value"
class="sb-absence-reason-btn" :class="{ active: absenceForm.reason === r.value }"
@click="absenceForm.reason = r.value">
{{ r.icon }} {{ r.label }}
</button>
</div>
<div class="sb-absence-dates">
<div>
<label class="sb-absence-lbl">Du</label>
<input type="date" class="sb-form-input" v-model="absenceForm.from" />
</div>
<div>
<label class="sb-absence-lbl">Jusqu'au <span class="sb-absence-opt">(optionnel)</span></label>
<input type="date" class="sb-form-input" v-model="absenceForm.until" />
</div>
</div>
<div v-if="store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length" class="sb-absence-jobs">
<label class="sb-absence-lbl">
{{ store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length }} job(s) assigné(s)
</label>
<div class="sb-absence-job-actions">
<label class="sb-absence-radio">
<input type="radio" v-model="absenceForm.jobAction" value="unassign" />
Désassigner (retour dans le pool non assigné)
</label>
<label class="sb-absence-radio">
<input type="radio" v-model="absenceForm.jobAction" value="keep" />
Garder assignés (réassigner manuellement après)
</label>
</div>
<div class="sb-absence-job-list">
<div v-for="j in store.jobs.filter(j => j.assignedTech === absenceModalTech?.id)" :key="j.id" class="sb-absence-job-item">
<span class="sb-absence-job-dot" :style="{ background: j.priority === 'urgent' ? '#ef4444' : j.priority === 'high' ? '#f59e0b' : '#6366f1' }"></span>
{{ j.subject }} <span class="sb-absence-job-date">{{ j.scheduledDate || '—' }}</span>
</div>
</div>
</div>
<div v-else class="sb-absence-no-jobs">Aucun job assigné actuellement.</div>
</div>
<template #footer>
<button class="sbf-primary-btn" @click="confirmAbsence" :disabled="absenceProcessing">
{{ absenceProcessing ? 'En cours...' : ' Confirmer l\'absence' }}
</button>
<button class="sb-rp-btn" @click="absenceModalOpen = false">Annuler</button>
</template>
</SbModal>
<!-- Schedule editor modal -->
<div v-if="scheduleModalTech" class="sb-overlay sb-overlay-top" @click.self="scheduleModalTech = null">
<div class="sb-modal sb-schedule-modal">
<div class="sb-modal-hdr">
<span>🗓 Horaire — {{ scheduleModalTech.fullName }}</span>
<button class="sb-rp-close" @click="scheduleModalTech = null">✕</button>
</div>
<div class="sb-schedule-presets">
<button v-for="p in SCHEDULE_PRESETS" :key="p.key" class="sb-preset-btn" @click="applySchedulePreset(p)">{{ p.label }}</button>
</div>
<div class="sb-schedule-grid">
<div v-for="d in WEEK_DAYS" :key="d" class="sb-schedule-day" :class="{ 'sb-schedule-off': !scheduleForm[d]?.on }">
<label class="sb-schedule-toggle">
<input type="checkbox" v-model="scheduleForm[d].on" />
<span class="sb-schedule-day-label">{{ DAY_LABELS[d] }}</span>
</label>
<template v-if="scheduleForm[d]?.on">
<input type="time" v-model="scheduleForm[d].start" class="sb-schedule-time" />
<span class="sb-schedule-sep">→</span>
<input type="time" v-model="scheduleForm[d].end" class="sb-schedule-time" />
<span class="sb-schedule-hours">{{ ((timeToH(scheduleForm[d].end) - timeToH(scheduleForm[d].start)) || 0).toFixed(1) }}h</span>
</template>
<span v-else class="sb-schedule-off-label">Repos</span>
</div>
</div>
<div class="sb-modal-footer">
<button class="sb-rp-btn" @click="scheduleModalTech = null">Annuler</button>
<button class="sb-rp-btn sb-rp-primary" @click="confirmSchedule">Enregistrer</button>
</div>
</div>
</div>
</div>
</template>
<style src="./dispatch-styles.scss"></style>