gigafibre-fsm/apps/ops/src/pages/DispatchPage.vue
louispaulb 43d5ca2e4e feat: nested tasks, project wizard, n8n webhooks, inline task editing
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>
2026-04-01 13:01:20 -04:00

958 lines
58 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 { 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">&times;</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">&times;</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>