gigafibre-fsm/apps/ops/src/pages/DispatchPage.vue
louispaulb a8eeb1b77d refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:38:38 -04:00

1321 lines
70 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, 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 {
localDateStr, timeToH, hToTime, 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'
import { useAddressSearch } from 'src/composables/useAddressSearch'
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()
function 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()
}
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) {
timeModal.value = { job, techId, time: getJobTime(job.id) || '08:00', hasPin: !!getJobTime(job.id) }
}
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 }
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))
function jobColor (job) { return _jobColorBase(job, TECH_COLORS, store) }
const PX_PER_HR = ref(80)
const ROW_H = 68
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 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)
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
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()
}
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 })
function 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,
})
function startAbsenceResize (e, seg, tech, side) {
e.preventDefault()
const startX = e.clientX
const block = e.target.closest('.sb-block-absence')
const startW = block.offsetWidth
const startL = parseFloat(block.style.left)
const SNAP_PX = pxPerHr.value / 4 // 15-minute snap
function snapPx (px) { return Math.round(px / SNAP_PX) * SNAP_PX }
function onMove (ev) {
const dx = ev.clientX - startX
if (side === 'right') {
block.style.width = Math.max(SNAP_PX, snapPx(startW + dx)) + 'px'
} else {
const newL = snapPx(startL + dx)
const newW = startW + (startL - newL)
if (newW >= SNAP_PX && newL >= 0) {
block.style.left = newL + 'px'
block.style.width = newW + 'px'
}
}
// Update label
const curL = parseFloat(block.style.left)
const curW = parseFloat(block.style.width)
const sH = H_START + curL / pxPerHr.value
const eH = sH + curW / pxPerHr.value
const lbl = block.querySelector('.sb-absence-label')
if (lbl) lbl.textContent = `${hToTime(sH)}${hToTime(eH)}`
}
function onUp () {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
const curL = parseFloat(block.style.left)
const curW = parseFloat(block.style.width)
const newStartH = H_START + curL / pxPerHr.value
const newEndH = newStartH + curW / pxPerHr.value
const startTime = hToTime(newStartH)
const endTime = hToTime(newEndH)
// Update local state
tech.absenceStartTime = startTime
tech.absenceEndTime = endTime
// Persist to ERPNext
updateTech(tech.name || tech.id, {
absence_start_time: startTime,
absence_end_time: endTime,
}).catch(() => {})
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
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)
})
function 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('')
// ── Schedule editor modal ──
import { WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS } from 'src/composables/useHelpers'
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' }
})
}
function 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
}
// Group technicians for the resource selector modal
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
}
function 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')
// Apply group filter within selector
if (resSelectorGroupFilter.value) techs = techs.filter(t => t.group === resSelectorGroupFilter.value)
// Apply search filter
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))),
}
})
const RES_ICONS = { 'Véhicule': '🚛', 'Outil': '🔧', 'Salle': '🏢', 'Équipement': '📦', 'Nacelle': '🏗️', 'Grue': '🏗️', 'Fusionneuse': '🔧', 'OTDR': '📡' }
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()
}
function 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) {
const job = 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 || '',
})
return job
}
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', periodLoadH)
provide('techPeriodCapacityH', techPeriodCapacityH)
provide('techDayEndH', techDayEndH)
provide('isJobMultiSelected', isJobMultiSelected)
provide('btColW', btColW)
provide('startColResize', startColResize)
provide('searchAddr', searchAddr)
provide('addrResults', addrResults)
provide('selectAddr', selectAddr)
// ── SSE for real-time tech absence updates ──
const HUB_SSE_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
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="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"
@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>
<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>
<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>
<div v-if="techTagModal" class="sb-overlay" @click.self="techTagModal=null">
<div class="sb-modal sb-modal-tags">
<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" style="overflow:visible;min-height:320px">
<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>Ressources & Groupes</span><button class="sb-rp-close" @click="resSelectorOpen=false">✕</button></div>
<div class="sb-modal-body">
<!-- Saved presets -->
<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>
<!-- Group chips -->
<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>
<!-- Search -->
<div class="sb-rsel-search-row">
<input v-model="resSelectorSearch" class="sb-rsel-search" placeholder="🔍 Rechercher une ressource…" />
</div>
<!-- Resource list -->
<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>
</div>
<div class="sb-modal-ftr">
<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>
</div>
</div>
</div>
<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" />
<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>
<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 (z-index above GPS modal) -->
<div v-if="absenceModalOpen" class="sb-overlay sb-overlay-top" @click.self="absenceModalOpen = false">
<div class="sb-modal sb-absence-modal">
<div class="sb-modal-hdr">
<span>⏸ Mettre en absence — {{ absenceModalTech?.fullName }}</span>
<button class="sb-rp-close" @click="absenceModalOpen = false">✕</button>
</div>
<div class="sb-modal-body">
<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>
</div>
<div class="sb-modal-ftr">
<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>
</div>
</div>
</div>
<!-- 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>