gigafibre-fsm/apps/ops/src/pages/DispatchPage.vue
louispaulb 16343b61e1 fix(ops/dispatch): top bar polish — visible ⋯ menu, collapsed AI, fly-to tech, views dropdown
Four fixes around the dispatch header following dispatcher feedback:

1. **⋯ overflow menu was invisible**: .sb-header had `overflow:hidden`,
   which clipped the absolutely-positioned dropdown right at the
   header's bottom edge. Switched the header to `overflow:visible`
   (children all have flex-shrink:0 + a flex:1 center, so the layout
   doesn't actually overflow horizontally). Bumped z-index to 5000
   for safety on top of map/calendar layers.

2. **NLP/Assistant IA bar hidden by default**: was eagerly rendering
   on every page load, with the long French placeholder polluting
   the header below the toolbar. The user just wanted the icon. Now
   `nlpVisible` defaults to false, persisted in localStorage so power
   users who flip it on keep it open across sessions. Toggle still
   lives in the ⋯ menu.

3. **Click a tech in the resource list now flies the map to them**:
   selectTechOnBoard previously only opened the map panel. Now it
   also `map.flyTo({ center })` using `tech.gpsCoords ?? tech.coords`
   — live Traccar position wins when the tech is online; falls back
   to the saved home base. Animated, deferred a tick so map.resize()
   happens first, otherwise flyTo can land on garbage coords during
   the panel's open transition.

4. **Board view tabs collapsed into a "Vue principale ▾" dropdown**:
   was [Vue principale][Par région][+] inline. Now a single button
   showing the active view; click reveals the others + the future
   "+ Nouvelle vue" entry. Same dropdown component as the ⋯ menu
   (shared CSS, click-outside + ESC close).
2026-05-05 14:17:33 -04:00

2066 lines
103 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 { Notify } from 'quasar'
import { useDispatchStore } from 'src/stores/dispatch'
import { useAuthStore } from 'src/stores/auth'
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
import { ROW_H, RES_ICONS, HUB_SSE_URL } from 'src/config/dispatch'
import { fetchOpenRequests } from 'src/api/service-request'
import { updateJob, updateTech } from 'src/api/dispatch'
import TagEditor from 'src/components/shared/TagEditor.vue'
import TimelineRow from 'src/modules/dispatch/components/TimelineRow.vue'
import BottomPanel from 'src/modules/dispatch/components/BottomPanel.vue'
import JobEditModal from 'src/modules/dispatch/components/JobEditModal.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import PublishScheduleModal from 'src/modules/dispatch/components/PublishScheduleModal.vue'
import WeekCalendar from 'src/modules/dispatch/components/WeekCalendar.vue'
import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue'
import RightPanel from 'src/modules/dispatch/components/RightPanel.vue'
import SbModal from 'src/modules/dispatch/components/SbModal.vue'
import SbContextMenu from 'src/modules/dispatch/components/SbContextMenu.vue'
import NlpInput from 'src/components/dispatch/NlpInput.vue'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
import {
localDateStr, fmtDate, timeToH, hToTime, fmtDur,
SVC_COLORS, prioLabel, prioClass, serializeAssistants,
jobColor as _jobColorBase, ICON, prioColor,
WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS,
buildRRule,
} from 'src/composables/useHelpers'
import { useScheduler } from 'src/composables/useScheduler'
import { useUndo } from 'src/composables/useUndo'
import { useMap } from 'src/composables/useMap'
import { useBottomPanel } from 'src/composables/useBottomPanel'
import { useDragDrop } from 'src/composables/useDragDrop'
import { useSelection } from 'src/composables/useSelection'
import { useAutoDispatch } from 'src/composables/useAutoDispatch'
import { usePeriodNavigation } from 'src/composables/usePeriodNavigation'
import { useResourceFilter } from 'src/composables/useResourceFilter'
import { useTagManagement } from 'src/composables/useTagManagement'
import { useContextMenus } from 'src/composables/useContextMenus'
import { useTechManagement } from 'src/composables/useTechManagement'
import { useAddressSearch } from 'src/composables/useAddressSearch'
import { useAbsenceResize } from 'src/composables/useAbsenceResize'
import { useJobOffers } from 'src/composables/useJobOffers'
import { fetchPresets, createPreset as apiCreatePreset, deletePreset as apiDeletePreset } from 'src/api/presets'
import OfferPoolPanel from 'src/modules/dispatch/components/OfferPoolPanel.vue'
import CreateOfferModal from 'src/modules/dispatch/components/CreateOfferModal.vue'
import RecurrenceSelector from 'src/components/shared/RecurrenceSelector.vue'
const store = useDispatchStore()
const auth = useAuthStore()
const erpUrl = BASE_URL || window.location.origin
// Offer pool (Uber-style job offers)
const {
offers, loadingOffers, showOfferPool, activeOfferCount,
loadOffers, broadcastOffer, handleAccept, handleDecline, handleCancel,
matchingTechs, estimateCost, offerExistingJob,
} = useJobOffers(store)
const createOfferModal = ref(false)
const createOfferPrefill = ref(null)
const {
currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr,
bufferDaysBefore, renderedDays,
prevPeriod, nextPeriod, goToToday, goToDay,
} = usePeriodNavigation()
// Mutable opts populated after useScheduler is initialized (callbacks capture by ref)
const _resFilterOpts = {
isAbsentOnDay: null,
getLoadH: null,
}
const {
selectedResIds, filterStatus, filterTags, filterResourceType, searchQuery, techSort, manualOrder,
filteredResources, groupedResources, availableGroups, filterGroup,
showInactive, hideAbsent, inactiveCount, humanCount, materialCount, availableCategories,
resSelectorOpen, tempSelectedIds, dragReorderTech,
openResSelector, applyResSelector, toggleTempRes, clearFilters,
onTechReorderStart, onTechReorderDrop,
} = useResourceFilter(store, _resFilterOpts)
const techTagModal = ref(null)
const {
getTagColor, onCreateTag, onUpdateTag, onRenameTag, onDeleteTag,
_serializeTags, persistJobTags, persistTechTags,
} = useTagManagement(store)
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
const setEndDate = (job, endDate) => {
job.endDate = endDate || null
updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {})
}
const toggleContinuous = (job, val) => {
job.continuous = !!val
store.jobVersion++
updateJob(job.name || job.id, { continuous: val ? 1 : 0 }).catch(() => {})
}
const filterPanelOpen = ref(false)
const projectsPanelOpen = ref(false)
const planningMode = ref(false) // Toggle: show shift availability blocks on timeline
const mapVisible = ref(localStorage.getItem('sbv2-map') === 'true')
const rightPanel = ref(null)
watch(mapVisible, v => localStorage.setItem('sbv2-map', v ? 'true' : 'false'))
const editModal = ref(null)
function openEditModal (job) {
editModal.value = {
job, subject: job.subject || '', address: job.address || '',
note: job.note || '', duration: job.duration || 1,
priority: job.priority || 'low', tags: [...(job.tags || [])],
latitude: job.latitude || null, longitude: job.longitude || null,
}
}
function confirmEdit () {
if (!editModal.value) return
const { job, subject, address, note, duration, priority, tags, latitude, longitude } = editModal.value
Object.assign(job, { subject, address, note, duration: parseFloat(duration) || 1, priority, tags: [...tags] })
if (latitude) job.latitude = latitude
if (longitude) job.longitude = longitude
updateJob(job.name || job.id, {
subject, address, note: note || '', duration_h: job.duration, priority,
tags: tags.map(t => ({ tag: t })),
latitude: latitude || null, longitude: longitude || null,
}).catch(() => {})
editModal.value = null
invalidateRoutes()
}
const getJobDate = jobId => store.jobs.find(j => j.id === jobId)?.scheduledDate || null
const getJobTime = jobId => store.jobs.find(j => j.id === jobId)?.startTime || null
function setJobTime (jobId, time) {
const job = store.jobs.find(j => j.id === jobId)
if (!job) return
store.setJobSchedule(jobId, job.scheduledDate, time || null)
job.startHour = time ? timeToH(time) : null
}
const timeModal = ref(null)
const openTimeModal = (job, techId) => { timeModal.value = { job, techId, time: getJobTime(job.id) || '08:00', hasPin: !!getJobTime(job.id) } }
const confirmTime = () => { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, timeModal.value.time); timeModal.value = null }
const clearTime = () => { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, null); timeModal.value = null }
const pendingReqs = ref([])
const pendingLoading = ref(false)
async function loadPendingReqs () {
pendingLoading.value = true
try { pendingReqs.value = await fetchOpenRequests() } catch { pendingReqs.value = [] }
pendingLoading.value = false
}
const unscheduledJobs = computed(() => store.jobs.filter(j => !j.assignedTech))
const teamJobs = computed(() => store.jobs.filter(j => j.assistants?.length > 0))
const jobColor = job => _jobColorBase(job, TECH_COLORS, store)
const PX_PER_HR = ref(80)
const pxPerHr = computed(() => currentView.value === 'week' ? PX_PER_HR.value * 0.55 : currentView.value === 'month' ? 0 : PX_PER_HR.value)
const dayW = computed(() => currentView.value === 'month' ? 110 : (H_END.value - H_START.value) * pxPerHr.value)
const totalW = computed(() => dayW.value * dayColumns.value.length)
const viewportW = computed(() => dayW.value * periodDays.value)
// Week calendar: fixed col width = available board width / 7 visible days
const calColW = ref(0)
function measureCalColW () {
const el = boardScroll.value
if (!el) return
const available = el.clientWidth - 200 // minus resource column
calColW.value = Math.floor(available / periodDays.value)
}
const {
H_START, H_END, routeLegs, routeGeometry,
techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, ghostOccurrencesForDate,
periodLoadH, techPeriodCapacityH, techDayEndH,
} = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor)
// Wire up resource filter opts now that scheduler is available
_resFilterOpts.isAbsentOnDay = (tech) => {
const dayStr = localDateStr(periodStart.value)
return absenceSegmentsForDate(tech, dayStr).length > 0
}
_resFilterOpts.getLoadH = (tech) => periodLoadH(tech)
const { startAbsenceResize } = useAbsenceResize(pxPerHr, H_START)
const hourTicks = computed(() => {
if (currentView.value === 'month') return []
const ticks = []
dayColumns.value.forEach((day, di) => {
for (let h = H_START.value; h <= H_END.value; h++) {
const x = di * dayW.value + (h - H_START.value) * pxPerHr.value
ticks.push({ x, label: h===H_START.value||h%2===0 ? h+':00' : null, isMajor: true, isDay: h===H_START.value, day, h })
}
})
return ticks
})
const isCalView = computed(() => currentView.value === 'week')
const unassignDropActive = ref(false)
// Pre-compute expensive per-tech data (memoized — avoids recalculating on every render)
const segmentsMap = computed(() => {
const map = {}
for (const tech of filteredResources.value) map[tech.id] = techDayJobsWithTravel(tech)
return map
})
const loadMap = computed(() => {
const map = {}
for (const tech of filteredResources.value) map[tech.id] = periodLoadH(tech)
return map
})
const capMap = computed(() => {
const map = {}
for (const tech of filteredResources.value) map[tech.id] = techPeriodCapacityH(tech)
return map
})
// ── Resource utilization alerts ──────────────────────────────────────────────
const overloadedTechs = computed(() => {
return filteredResources.value.filter(tech => {
const load = loadMap.value[tech.id] || 0
const cap = capMap.value[tech.id] || 8
return load > cap
}).map(tech => ({
tech,
load: loadMap.value[tech.id] || 0,
cap: capMap.value[tech.id] || 8,
pct: Math.round(((loadMap.value[tech.id] || 0) / (capMap.value[tech.id] || 8)) * 100),
}))
})
const underutilizedTechs = computed(() => {
return filteredResources.value.filter(tech => {
if (tech.status === 'off' || tech.status === 'inactive') return false
const load = loadMap.value[tech.id] || 0
const cap = capMap.value[tech.id] || 8
return cap > 0 && load < cap * 0.3
})
})
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
const smartAssign = (job, newTechId, dateStr) => store.smartAssign(job.id, newTechId, dateStr)
// Confirmation state for unassign
const confirmUnassignDialog = ref(false)
const pendingUnassignJob = ref(null)
function _doUnassign (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()
}
function fullUnassign (job) {
// Require confirmation for published or in-progress jobs
if (job.published || job.status === 'in_progress' || job.status === 'In Progress' || job.status === 'assigned') {
pendingUnassignJob.value = job
confirmUnassignDialog.value = true
return
}
_doUnassign(job)
}
function confirmUnassign () {
if (pendingUnassignJob.value) _doUnassign(pendingUnassignJob.value)
pendingUnassignJob.value = null
confirmUnassignDialog.value = false
}
function cancelUnassign () {
pendingUnassignJob.value = null
confirmUnassignDialog.value = false
}
const {
ctxMenu, techCtx, assistCtx, assistNoteModal,
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
assistCtxTogglePin, assistCtxRemove, assistCtxNote, confirmAssistNote,
ctxDetails, ctxMove, ctxUnschedule,
} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal })
const {
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
btColWidths, btColW, startColResize,
} = useBottomPanel(store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart })
const {
hoveredJobId, selectedJob, multiSelect,
selectJob: _selectJob, isJobMultiSelected, batchUnassign, batchMoveTo,
lasso, boardScroll, lassoStyle, startLasso, moveLasso, endLasso,
techHasLinkedJob, techIsHovered,
} = useSelection({ store, periodStart, smartAssign, invalidateRoutes, fullUnassign })
const selectJob = (job, techId, isAssist = false, assistTechId = null, event = null) => _selectJob(job, techId, isAssist, assistTechId, event, rightPanel)
const {
dragJob, dragSrc, dragIsAssist, dragBatchIds, dropGhost,
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
onTechDragStart,
onTimelineDrop, onCalDrop,
startBlockMove, startResize,
} = useDragDrop({
store, pxPerHr, dayW, periodStart, periodDays, H_START,
getJobDate, bottomSelected, multiSelect,
pushUndo, smartAssign, invalidateRoutes,
})
let computeDayRoute = () => {}, drawMapMarkers = () => {}, drawSelectedRoute = () => {}, getMap = () => null
const _map = useMap({
store, MAPBOX_TOKEN, TECH_COLORS,
currentView, periodStart, filteredResources, mapVisible,
routeLegs, routeGeometry,
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
dragJob, dragIsAssist, rightPanel, openCtxMenu, openTechCtx,
// Forward-binding through an arrow: `saveTechHome` is destructured
// from useTechManagement BELOW this call, so we can't pass the
// function value directly here (TDZ). The arrow defers the lookup
// to invocation time — by which point the const is defined.
saveTechHome: (tech, lng, lat) => saveTechHome(tech, lng, lat),
})
const { mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, geoFixTech, mapDragJob,
startGeoFix, cancelGeoFix, startTechGeoFix, cancelTechGeoFix,
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)
// NLP bar is hidden by default; toggled from the ⋯ menu (Assistant IA).
// Showing it eagerly bloats the header on narrow laptops and the
// example placeholder text added visual noise. Persist the user's
// preference in localStorage so power users keep it open if they want.
const nlpVisible = ref(localStorage.getItem('sbv2-nlp-visible') === '1')
watch(nlpVisible, v => localStorage.setItem('sbv2-nlp-visible', v ? '1' : '0'))
const draftCount = computed(() => store.jobs.filter(j => !j.published && j.status !== 'completed' && j.status !== 'cancelled').length)
function onNlpAction (result) {
if (!result) return
// Handle the parsed NLP action
if (result.action === 'create_job') {
// Pre-fill WO modal with NLP-extracted data
openWoModal()
Notify.create({ type: 'info', message: `IA: Création suggérée — "${result.subject || ''}"`, timeout: 3000 })
} else if (result.action === 'redistribute') {
Notify.create({ type: 'warning', message: `IA: Redistribution suggérée pour ${result.absent_tech || '?'}`, timeout: 4000 })
} else if (result.action === 'move_job') {
Notify.create({ type: 'info', message: `IA: Déplacer job de ${result.from_tech} vers ${result.to_tech}`, timeout: 4000 })
}
}
const periodEndStr = computed(() => {
const ps = periodStart.value
if (!ps || isNaN(ps.getTime())) return ''
const d = new Date(ps)
d.setDate(d.getDate() + (periodDays.value || 7) - 1)
return localDateStr(d)
})
const onPublished = jobNames => store.publishJobsLocal(jobNames)
const moreMenuOpen = ref(false) // ⋯ dropdown in the top toolbar (right side)
const viewsMenuOpen = ref(false) // "Vue principale ▾" dropdown (left side)
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, saveTechHome,
ABSENCE_REASONS,
} = useTechManagement(store, invalidateRoutes)
const newTechGroup = ref('')
const scheduleModalTech = ref(null)
const scheduleForm = ref({})
const extraShiftsForm = ref([]) // On-call / garde shifts
// Edit a tech's home/departure coordinates. Three paths now converge:
// • Type an address → free Nominatim geocode → confirm → save
// • Type "lat, lng" directly (advanced)
// • Pick the location by clicking on the map (geoFixTech mode)
// We don't proxy the geocode through targo-hub on purpose: Nominatim
// allows browser calls with a sane User-Agent and there's no secret
// involved.
async function openTechHomeDialog (tech) {
const cur = tech.coords || [-73.6756177, 45.1599145]
// Step 1: ask the user how they want to set it.
$q.dialog({
title: `Position de départ — ${tech.fullName}`,
message: `<div class="text-caption text-grey-7">
Actuelle: <code>${cur[1].toFixed(5)}, ${cur[0].toFixed(5)}</code>
</div>
<div class="text-caption q-mt-sm">Comment veux-tu la définir ?</div>`,
html: true,
options: {
type: 'radio',
model: 'address',
items: [
{ label: '📝 Saisir une adresse (ou "lat, lng")', value: 'address' },
{ label: '🎯 Cliquer sur la carte', value: 'map' },
],
},
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'primary', unelevated: true, label: 'Continuer' },
}).onOk(method => {
if (method === 'map') {
startTechGeoFix(tech)
Notify.create({ type: 'info', message: `Cliquez sur la carte pour ${tech.fullName}`, position: 'top', timeout: 3000 })
return
}
// method === 'address' → ask for the address string
promptTechHomeAddress(tech)
})
}
async function promptTechHomeAddress (tech) {
const dialog = $q.dialog({
title: `Adresse de départ — ${tech.fullName}`,
message: `<div class="text-caption text-grey-7">Adresse à géocoder, ou colle directement <code>lat, lng</code>.</div>`,
html: true,
prompt: {
model: '',
type: 'text',
outlined: true,
label: 'Adresse OU "lat, lng"',
placeholder: '1867 chemin de la Rivière, Sainte-Clotilde',
counter: false,
},
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'primary', unelevated: true, label: 'Géocoder + Enregistrer' },
persistent: false,
})
dialog.onOk(async (input) => {
const txt = (input || '').trim()
if (!txt) return
// Direct lat/lng paste? Pattern: "45.16, -73.67"
const direct = txt.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/)
if (direct) {
const lat = parseFloat(direct[1]); const lng = parseFloat(direct[2])
try {
await saveTechHome(tech, lng, lat)
Notify.create({ type: 'positive', message: `Position mise à jour pour ${tech.fullName}`, position: 'top', timeout: 2500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, position: 'top' })
}
return
}
// Geocode via Nominatim
try {
const r = await fetch(
'https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(txt),
{ headers: { 'Accept-Language': 'fr-CA,fr,en' } }
)
const data = await r.json()
if (!data?.length) {
Notify.create({ type: 'negative', message: 'Adresse introuvable', position: 'top' })
return
}
const hit = data[0]
const lat = parseFloat(hit.lat); const lng = parseFloat(hit.lon)
$q.dialog({
title: 'Confirmer la position',
message: `<div>${hit.display_name}</div><div class="text-caption text-grey-7 q-mt-sm"><code>${lat.toFixed(5)}, ${lng.toFixed(5)}</code></div>`,
html: true,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'primary', unelevated: true, label: 'Enregistrer' },
}).onOk(async () => {
try {
await saveTechHome(tech, lng, lat)
Notify.create({ type: 'positive', message: `Position mise à jour pour ${tech.fullName}`, position: 'top', timeout: 2500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, position: 'top' })
}
})
} catch (e) {
Notify.create({ type: 'negative', message: 'Géocodage indisponible: ' + e.message, position: 'top' })
}
})
}
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' }
})
// Load existing extra shifts with UI-friendly _pattern/_interval
extraShiftsForm.value = (tech.extraShifts || []).map(s => _enrichShift({ ...s }))
}
const applySchedulePreset = preset => {
WEEK_DAYS.forEach(d => {
const day = preset.schedule[d]
scheduleForm.value[d] = day ? { on: true, start: day.start, end: day.end } : { on: false, start: '08:00', end: '16:00' }
})
}
function _parseShiftPattern (rrule) {
if (!rrule) return { pattern: 'weekend', interval: 1 }
if (rrule.includes('BYDAY=MO,TU,WE,TH,FR')) return { pattern: 'weeknight', interval: 1 }
if (rrule.includes('FREQ=DAILY')) return { pattern: 'daily', interval: 1 }
const m = rrule.match(/INTERVAL=(\d+)/)
return { pattern: 'weekend', interval: m ? parseInt(m[1]) : 1 }
}
function _enrichShift (s) {
const p = _parseShiftPattern(s.rrule)
s._pattern = p.pattern
s._interval = p.interval
return s
}
function updateShiftRrule (shift) {
if (shift._pattern === 'weekend') {
shift.rrule = `FREQ=WEEKLY;INTERVAL=${shift._interval || 1};BYDAY=SA,SU`
} else if (shift._pattern === 'weeknight') {
shift.rrule = 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR'
} else if (shift._pattern === 'daily') {
shift.rrule = 'FREQ=DAILY;INTERVAL=1'
}
}
function addExtraShift () {
extraShiftsForm.value.push(_enrichShift({
label: 'Garde',
startTime: '08:00',
endTime: '16:00',
rrule: 'FREQ=WEEKLY;INTERVAL=4;BYDAY=SA,SU',
from: todayStr.value || localDateStr(new Date()),
}))
}
function removeExtraShift (idx) {
extraShiftsForm.value.splice(idx, 1)
}
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)
// Save extra shifts (strip transient UI fields)
const tech = scheduleModalTech.value
tech.extraShifts = extraShiftsForm.value
.filter(s => s.startTime && s.endTime && s.rrule)
.map(({ _pattern, _interval, ...rest }) => rest)
updateTech(tech.name || tech.id, { extra_shifts: JSON.stringify(tech.extraShifts) }).catch(() => {})
store.jobVersion++
scheduleModalTech.value = null
}
const resSelectorGroupFilter = ref('')
const resSelectorSearch = ref('')
const savedPresets = ref([])
const presetsLoaded = ref(false)
const presetNameInput = ref('')
const showPresetSave = ref(false)
// Migrate localStorage presets to ERPNext (one-time)
async function _migrateLocalPresets () {
const local = JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]')
if (!local.length) return
for (const p of local) {
if (savedPresets.value.some(sp => sp.name === p.name)) continue
try {
await apiCreatePreset({
preset_name: p.name,
preset_type: p.type || 'selection',
group_name: p.group || '',
tech_ids: JSON.stringify(p.ids || []),
is_shared: 1,
})
} catch {}
}
localStorage.removeItem('sbv2-resPresets')
}
async function loadPresets () {
try {
const raw = await fetchPresets()
savedPresets.value = raw.map(p => ({
name: p.preset_name || p.name,
docName: p.name,
type: p.preset_type || 'selection',
group: p.group_name || '',
ids: p.tech_ids ? JSON.parse(p.tech_ids) : [],
shared: !!p.is_shared,
createdBy: p.created_by_user || p.owner || '',
}))
presetsLoaded.value = true
// One-time migration
if (localStorage.getItem('sbv2-resPresets')) _migrateLocalPresets().then(loadPresets)
} catch (e) {
console.warn('[presets] load failed:', e.message)
// Fallback to localStorage
savedPresets.value = JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]')
}
}
async function savePreset () {
const name = presetNameInput.value.trim()
if (!name || !tempSelectedIds.value.length) return
const existing = savedPresets.value.find(p => p.name === name)
try {
if (existing?.docName) {
await import('src/api/presets').then(m => m.updatePreset(existing.docName, { tech_ids: JSON.stringify(tempSelectedIds.value) }))
} else {
await apiCreatePreset({
preset_name: name,
preset_type: 'selection',
tech_ids: JSON.stringify([...tempSelectedIds.value]),
is_shared: 1,
})
}
await loadPresets()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
presetNameInput.value = ''
showPresetSave.value = false
}
async function saveGroupAsPreset (groupName) {
if (!groupName) return
const techsInGroup = store.technicians.filter(t => t.group === groupName && t.status !== 'inactive')
if (!techsInGroup.length) return
try {
const existing = savedPresets.value.find(p => p.name === groupName && p.type === 'group')
if (existing?.docName) {
await import('src/api/presets').then(m => m.updatePreset(existing.docName, { tech_ids: JSON.stringify(techsInGroup.map(t => t.id)) }))
} else {
await apiCreatePreset({
preset_name: groupName,
preset_type: 'group',
group_name: groupName,
tech_ids: JSON.stringify(techsInGroup.map(t => t.id)),
is_shared: 1,
})
}
await loadPresets()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
function loadPreset (preset) {
if (preset.type === 'group' && preset.group) {
const techsInGroup = store.technicians.filter(t => t.group === preset.group && t.status !== 'inactive')
tempSelectedIds.value = techsInGroup.map(t => t.id)
} else {
tempSelectedIds.value = [...(preset.ids || [])]
}
}
async function deletePreset (idx) {
const preset = savedPresets.value[idx]
if (preset?.docName) {
try { await apiDeletePreset(preset.docName) } catch {}
}
savedPresets.value.splice(idx, 1)
}
function quickLoadPreset (preset) {
if (activePresetName.value === preset.name) {
selectedResIds.value = []
filterGroup.value = ''
return
}
if (preset.type === 'group' && preset.group) {
filterGroup.value = preset.group
selectedResIds.value = []
} else {
filterGroup.value = ''
selectedResIds.value = [...(preset.ids || [])]
}
}
const activePresetName = computed(() => {
// Check group presets via filterGroup
if (filterGroup.value) {
const gp = savedPresets.value.find(p => p.type === 'group' && p.group === filterGroup.value)
if (gp) return gp.name
}
// Check ID-based presets
if (!selectedResIds.value.length) return null
const ids = selectedResIds.value
return savedPresets.value.find(p => p.ids.length === ids.length && p.ids.every(id => ids.includes(id)))?.name || null
})
const resSelectorGroupsFiltered = computed(() => {
const grouper = (techs) => {
const groups = new Map()
for (const t of techs) {
const g = t.group || ''
if (!groups.has(g)) groups.set(g, [])
groups.get(g).push(t)
}
const sorted = [...groups.entries()].sort((a, b) => {
if (!a[0] && b[0]) return 1
if (a[0] && !b[0]) return -1
return a[0].localeCompare(b[0])
})
return sorted.map(([name, techs]) => ({ name, label: name || 'Sans groupe', techs }))
}
let techs = store.technicians.filter(t => t.status !== 'inactive')
if (resSelectorGroupFilter.value) techs = techs.filter(t => t.group === resSelectorGroupFilter.value)
if (resSelectorSearch.value) {
const q = resSelectorSearch.value.toLowerCase()
techs = techs.filter(t => t.fullName.toLowerCase().includes(q))
}
return {
available: grouper(techs.filter(x => !tempSelectedIds.value.includes(x.id))),
selected: grouper(store.technicians.filter(x => tempSelectedIds.value.includes(x.id))),
}
})
function resIcon (t) {
if (t.resourceType !== 'material') return ''
return RES_ICONS[t.resourceCategory] || RES_ICONS[t.fullName] || '🔧'
}
function openResSelectorFull () {
resSelectorGroupFilter.value = filterGroup.value
resSelectorSearch.value = ''
openResSelector()
}
const applyGroupFilter = () => { filterGroup.value = resSelectorGroupFilter.value; resSelectorOpen.value = false }
async function onTechStatusChange (tech, value) {
tech.status = value
tech.active = value !== 'inactive'
saveTechField(tech, 'status', value)
}
async function saveTechGroup (tech, value) {
const trimmed = (value || '').trim()
if (trimmed === tech.group) return
tech.group = trimmed
try { await import('src/api/dispatch').then(m => m.updateTech(tech.name || tech.id, { tech_group: trimmed })) }
catch {}
}
function openWoModal (prefillDate = null, prefillTech = null) {
woModalCtx.value = { scheduled_date: prefillDate || todayStr, assigned_tech: prefillTech || null }
woModalOpen.value = true
}
async function confirmWo (formData) {
return await store.createJob({
subject: formData.subject,
address: formData.address,
duration_h: formData.duration_h,
priority: formData.priority,
assigned_tech: formData.assigned_tech || null,
scheduled_date: formData.scheduled_date || null,
latitude: formData._latitude || null,
longitude: formData._longitude || null,
note: formData.description || '',
tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t),
depends_on: formData.depends_on || '',
})
}
const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs,
bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes,
})
function optimizeRoute () {
if (!techCtx.value) return
const tech = techCtx.value.tech; techCtx.value = null
_optimizeRoute(tech)
}
// ── Offer handlers ──────────────────────────────────────────────────────────
async function onOfferAccept (offer) {
// Show a quick tech picker for manual assignment from dispatcher side
const techs = matchingTechs(offer)
if (!techs.length) {
Notify.create({ type: 'warning', message: 'Aucun tech disponible pour cette offre', timeout: 3000 })
return
}
// For now, auto-assign to the first/best matching tech
// TODO: show a picker modal for dispatcher to choose
try {
await handleAccept(offer.id, techs[0].id)
Notify.create({ type: 'positive', message: `Offre acceptée → ${techs[0].fullName}`, timeout: 3000 })
invalidateRoutes()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
}
}
function offerUnassignedJob (job) {
createOfferPrefill.value = job
createOfferModal.value = true
}
async function onCreateOffer (formData, sms) {
try {
await broadcastOffer(formData, sms)
Notify.create({ type: 'positive', message: 'Offre diffusée' + (sms ? ' (SMS envoyés)' : ''), timeout: 3000 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
}
}
// ── Ghost / Recurring handlers ────────────────────────────────────────────
function onGhostClick (templateJob, dateStr, techId) {
const tech = store.technicians.find(t => t.id === techId)
bookingOverlay.value = { job: templateJob, tech, ghostDate: dateStr }
}
async function materializeGhost (templateJob, dateStr, techId) {
const newJob = await store.createJob({
subject: templateJob.subject,
address: templateJob.address,
longitude: templateJob.coords?.[0],
latitude: templateJob.coords?.[1],
duration_h: templateJob.duration,
priority: templateJob.priority,
assigned_tech: techId,
scheduled_date: dateStr,
start_time: templateJob.startTime || '',
customer: templateJob.customer || '',
service_location: templateJob.serviceLocation || '',
template_id: templateJob.id,
})
store.jobVersion++
invalidateRoutes()
Notify.create({ type: 'positive', message: `Job matérialisé pour le ${dateStr}`, timeout: 2000 })
}
function toggleRecurring (job) {
job.isRecurring = !job.isRecurring
if (job.isRecurring && !job.recurrenceRule) job.recurrenceRule = 'FREQ=WEEKLY;BYDAY=MO'
store.jobVersion++
updateJob(job.name || job.id, {
is_recurring: job.isRecurring ? 1 : 0,
recurrence_rule: job.recurrenceRule || '',
}).catch(() => {})
}
function updateRecurrence (job, rrule) {
job.recurrenceRule = rrule
store.jobVersion++
updateJob(job.name || job.id, { recurrence_rule: rrule }).catch(() => {})
}
function updateRecurrenceEnd (job, endDate) {
job.recurrenceEnd = endDate || null
store.jobVersion++
updateJob(job.name || job.id, { recurrence_end: endDate || '' }).catch(() => {})
}
function addPausePeriod (job) {
const pauses = [...(job.pausePeriods || []), { from: localDateStr(new Date()), until: '' }]
job.pausePeriods = pauses
store.jobVersion++
updateJob(job.name || job.id, { pause_periods: JSON.stringify(pauses) }).catch(() => {})
}
function updatePausePeriod (job, idx, field, val) {
const pauses = [...(job.pausePeriods || [])]
pauses[idx] = { ...pauses[idx], [field]: val }
job.pausePeriods = pauses
store.jobVersion++
updateJob(job.name || job.id, { pause_periods: JSON.stringify(pauses) }).catch(() => {})
}
function removePausePeriod (job, idx) {
const pauses = [...(job.pausePeriods || [])]
pauses.splice(idx, 1)
job.pausePeriods = pauses
store.jobVersion++
updateJob(job.name || job.id, { pause_periods: JSON.stringify(pauses) }).catch(() => {})
}
async function copyIcalUrl (tech) {
const { HUB_URL: hubBase } = await import('src/config/hub')
try {
const r = await fetch(`${hubBase}/dispatch/ical-token/${tech.id}`)
const data = await r.json()
const url = `${hubBase}/dispatch/calendar/${tech.id}.ics?token=${data.token}`
await navigator.clipboard.writeText(url)
Notify.create({ type: 'positive', message: `Lien iCal copié pour ${tech.fullName}`, caption: 'Collez dans Google Calendar ou iPhone', timeout: 3500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
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 = []
moreMenuOpen.value = false
viewsMenuOpen.value = false
if (geoFixTech.value) cancelTechGeoFix()
}
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()
}
}
function _dateRange () {
const start = localDateStr(periodStart.value)
const end = localDateStr(new Date(periodStart.value.getTime() + (periodDays.value - 1) * 86400000))
return [start, end]
}
async function refreshData () {
const prevTechId = selectedTechId.value
await store.loadAll()
const savedCoords = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
Object.entries(savedCoords).forEach(([jobId, coords]) => { const job = store.jobs.find(j => j.id === jobId); if (job) job.coords = coords })
routeLegs.value = {}; routeGeometry.value = {}
const _ds = localDateStr(periodStart.value)
filteredResources.value.forEach(tech => computeDayRoute(tech, _ds))
const pj = bookingOverlay.value?.job?.id, pt = bookingOverlay.value?.tech?.id
if (pj) { const j = store.jobs.find(j => j.id === pj); const t = pt ? store.technicians.find(t => t.id === pt) : null; if (j) bookingOverlay.value = { job: j, tech: t } }
selectedTechId.value = prevTechId
if (getMap()) { drawMapMarkers(); drawSelectedRoute() }
await loadPendingReqs()
}
provide('store', store)
provide('TECH_COLORS', TECH_COLORS)
provide('MAPBOX_TOKEN', MAPBOX_TOKEN)
provide('jobColor', jobColor)
provide('getTagColor', getTagColor)
provide('onCreateTag', onCreateTag)
provide('onUpdateTag', onUpdateTag)
provide('onRenameTag', onRenameTag)
provide('onDeleteTag', onDeleteTag)
provide('selectedJob', selectedJob)
provide('hoveredJobId', hoveredJobId)
provide('periodLoadH', (tech) => loadMap.value[tech.id] ?? 0)
provide('techPeriodCapacityH', (tech) => capMap.value[tech.id] ?? 8)
provide('techDayEndH', techDayEndH)
provide('isJobMultiSelected', isJobMultiSelected)
provide('ghostOccurrencesForDate', ghostOccurrencesForDate)
provide('planningMode', planningMode)
provide('btColW', btColW)
provide('startColResize', startColResize)
provide('searchAddr', searchAddr)
provide('addrResults', addrResults)
provide('selectAddr', selectAddr)
let dispatchSse = null
function connectDispatchSSE () {
if (dispatchSse) dispatchSse.close()
dispatchSse = new EventSource(`${HUB_SSE_URL}/sse?topics=dispatch,network`)
// Network outage alerts — toast for dispatchers
dispatchSse.addEventListener('outage-alert', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'warning', icon: 'warning',
message: `⚠ Panne: ${d.affected_count} clients — ${d.oltName} port ${d.port}`,
caption: d.analysis?.outage_type || '',
timeout: 10000,
actions: [{ icon: 'close', color: 'white', round: true }],
})
} catch {}
})
dispatchSse.addEventListener('dispatch-suggestion', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'info', icon: 'engineering',
message: `Dispatch suggéré: ${d.tech_skill_needed || 'technicien'}`,
caption: d.subject,
timeout: 15000,
actions: [{ icon: 'close', color: 'white', round: true }],
})
} catch {}
})
dispatchSse.addEventListener('outage-resolved', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'positive', icon: 'check_circle',
message: `✓ Panne résolue: ${d.oltName} port ${d.port}`,
timeout: 5000,
})
} catch {}
})
// OLT-level events
dispatchSse.addEventListener('olt-down', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'negative', icon: 'cell_tower',
message: `🔴 OLT hors service: ${d.oltName}`,
caption: `~${d.customerCount} clients affectés (${d.source})`,
timeout: 0, // persistent — must dismiss manually
actions: [{ icon: 'close', color: 'white', round: true }],
})
} catch {}
})
dispatchSse.addEventListener('olt-up', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'positive', icon: 'cell_tower',
message: `✅ OLT rétabli: ${d.oltName}`,
caption: d.downtime_min ? `Panne: ${d.downtime_min} min` : '',
timeout: 8000,
})
} catch {}
})
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 = ''
}
// Bust scheduler cache so timeline re-renders with absence change
store.jobVersion++
} catch {}
})
}
// Reload jobs when period changes (navigating weeks/days)
watch([periodStart, () => periodDays.value], () => {
if (store.technicians.length) refreshData()
nextTick(() => scrollToCenter())
})
// ── Infinite scroll: native horizontal scroll through 3 rendered periods ──
// When scroll reaches a full period past center, shift anchor and re-center silently.
let _recentering = false
// Per-day pixel width — depends on current view mode
function _colPx () {
return isCalView.value ? calColW.value : dayW.value
}
function scrollToCenter () {
const el = boardScroll.value
if (!el || currentView.value === 'month' || currentView.value === 'day') return
_recentering = true
el.scrollLeft = bufferDaysBefore.value * _colPx()
requestAnimationFrame(() => { _recentering = false })
}
function onBoardScroll () {
if (_recentering || currentView.value === 'month' || currentView.value === 'day') return
const el = boardScroll.value
if (!el) return
const cpx = _colPx()
if (!cpx) return
const periodW = periodDays.value * cpx
const centerScroll = bufferDaysBefore.value * cpx // scrollLeft where current period starts
// Scrolled 1 full period past current → advance anchor, re-center
// Trigger when entering the last buffer week (not at the very edge)
if (el.scrollLeft > centerScroll + periodW - cpx) {
nextPeriod()
nextTick(() => scrollToCenter())
}
// Scrolled 1 full period before current → go back, re-center
else if (el.scrollLeft < cpx) {
prevPeriod()
nextTick(() => scrollToCenter())
}
}
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))
// Non-blocking: don't wait for pending requests before rendering
loadPendingReqs()
loadOffers()
loadPresets()
document.addEventListener('keydown', onKeyDown)
document.addEventListener('click', () => { closeCtxMenu(); assistCtx.value = null; techCtx.value = null; moreMenuOpen.value = false; viewsMenuOpen.value = false })
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()
// Measure column width for week calendar, scroll to center
measureCalColW()
nextTick(() => scrollToCenter())
if (boardScroll.value) boardScroll.value.addEventListener('scroll', onBoardScroll, { passive: true })
window.addEventListener('resize', measureCalColW)
})
// Re-measure column widths when switching views (day→week changes periodDays 1→7)
watch([currentView, periodDays], () => nextTick(() => { measureCalColW(); scrollToCenter() }))
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('click', closeCtxMenu)
if (boardScroll.value) boardScroll.value.removeEventListener('scroll', onBoardScroll)
window.removeEventListener('resize', measureCalColW)
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>
<!-- Quick saved group access -->
<div v-if="savedPresets.length" class="sb-quick-presets">
<button v-for="p in savedPresets" :key="p.name"
class="sb-quick-preset" :class="{ active: activePresetName === p.name }"
@click.stop="quickLoadPreset(p)"
:title="(p.type === 'group' ? 'Groupe: ' : 'Sélection: ') + p.name">
<span v-if="p.type === 'group'" class="sb-qp-icon">👥</span>{{ p.name }}
</button>
</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>
<!-- Board view selector — a single dropdown instead of all tabs
inline. Saves header width on narrow laptops; the chevron
hints there are more views available. -->
<div class="sb-menu-wrap">
<button class="sb-icon-btn sb-tab-current" :class="{ active: viewsMenuOpen }"
@click.stop="viewsMenuOpen = !viewsMenuOpen" title="Changer de vue">
{{ activeTab }} <span class="sb-tab-chev">▾</span>
</button>
<div v-if="viewsMenuOpen" class="sb-menu-dropdown sb-menu-dropdown-left" @click.stop>
<button v-for="tab in boardTabs" :key="tab" class="sb-menu-item"
:class="{ active: activeTab===tab }"
@click="activeTab=tab; viewsMenuOpen=false">
{{ tab }}
</button>
<div class="sb-menu-sep"></div>
<button class="sb-menu-item" disabled title="Bientôt"><span class="sb-menu-icon">+</span> Nouvelle vue</button>
</div>
</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||hideAbsent" 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>
<button class="sb-icon-btn sb-planning-toggle" :class="{ active: planningMode }" @click="planningMode=!planningMode" title="Mode planification — afficher les disponibilités">
🗓 <span v-if="currentView!=='month'" style="font-size:0.72rem">Planning</span>
</button>
</div>
<div class="sb-header-right">
<!-- ─────────────────── PRIMAIRE — workflow quotidien ───────────────────
Sont gardés en première ligne ceux qui ont un statut visuel important
(badges, surcharge) ou qui sont des CTA principaux (Publier, + WO).
Le reste descend dans le menu ⋯ pour libérer la largeur. -->
<span v-if="overloadedTechs.length" class="sb-overload-alert" :title="overloadedTechs.map(o => o.tech.fullName + ' ' + o.pct + '%').join(', ')">
⚠️ {{ overloadedTechs.length }} surchargé{{ overloadedTechs.length > 1 ? 's' : '' }}
</span>
<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-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>
<!-- ─────────────────── MENU ⋯ — secondaire / admin ─────────────────────
Plus rarement utilisé : refresh, assistant IA, offres aux techs,
ressources/GPS, lien ERPNext. Tout ce qui n'est pas dans le hot
path du dispatcher au quotidien. -->
<div class="sb-menu-wrap">
<button class="sb-icon-btn sb-menu-btn" :class="{ active: moreMenuOpen }" @click.stop="moreMenuOpen = !moreMenuOpen" title="Plus d'options">⋯</button>
<div v-if="moreMenuOpen" class="sb-menu-dropdown" @click.stop>
<button class="sb-menu-item" @click="refreshData(); moreMenuOpen=false">
<span class="sb-menu-icon">↻</span> Actualiser
</button>
<button class="sb-menu-item" :class="{ active: nlpVisible }" @click="nlpVisible=!nlpVisible; moreMenuOpen=false">
<span class="sb-menu-icon">✨</span> Assistant IA
</button>
<button class="sb-menu-item" :class="{ active: showOfferPool }" @click="showOfferPool=!showOfferPool; if(showOfferPool) loadOffers(); moreMenuOpen=false">
<span class="sb-menu-icon">📡</span> Offres aux techs
<span v-if="activeOfferCount" class="sbs-count" style="margin-left:auto;background:#4ade80;color:#000">{{ activeOfferCount }}</span>
</button>
<div class="sb-menu-sep"></div>
<button class="sb-menu-item" @click="gpsSettingsOpen=true; moreMenuOpen=false">
<span class="sb-menu-icon">👥</span> Ressources &amp; GPS
</button>
<div class="sb-menu-sep"></div>
<a class="sb-menu-item" :href="erpUrl + '/desk'" target="_blank" @click="moreMenuOpen=false">
<span class="sb-menu-icon">↗</span> Ouvrir ERPNext
<span class="sb-erp-dot" :class="{ ok: store.erpStatus==='ok' }" style="margin-left:auto" :title="{ ok:'ERPNext ✓', error:'Hors ligne', loading:'Connexion…' }[store.erpStatus]||'ERPNext'"></span>
</a>
</div>
</div>
</div>
</header>
<!-- AI Natural Language Input -->
<div class="sb-nlp-row" v-show="nlpVisible">
<NlpInput :tech-names="store.technicians.map(t => t.fullName)" @applied="onNlpAction" />
</div>
<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="load">Moins chargés d'abord</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" style="margin-top:0.4rem">
<input type="checkbox" v-model="hideAbsent" style="margin-right:4px;vertical-align:middle" />
Disponibles seulement
</label>
<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||hideAbsent" 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 v-if="bookingOverlay.job?.endDate" class="sb-rp-field" style="display:flex;align-items:center;gap:8px">
<label style="display:flex;align-items:center;gap:4px;font-size:0.72rem;cursor:pointer">
<input type="checkbox" :checked="bookingOverlay.job?.continuous" @change="toggleContinuous(bookingOverlay.job, $event.target.checked)" />
Urgence / continu (inclure fins de semaine)
</label>
</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>
<!-- Recurrence -->
<div class="sb-rp-field" style="border-top:1px solid rgba(255,255,255,0.08);padding-top:8px;margin-top:4px">
<label style="display:flex;align-items:center;gap:4px;font-size:0.72rem;cursor:pointer">
<input type="checkbox" :checked="bookingOverlay.job?.isRecurring" @change="toggleRecurring(bookingOverlay.job)" />
🔄 Récurrence
</label>
</div>
<template v-if="bookingOverlay.job?.isRecurring">
<div class="sb-rp-field">
<RecurrenceSelector
:model-value="bookingOverlay.job.recurrenceRule || ''"
:ref-date="bookingOverlay.job.scheduledDate || todayStr"
:show-none="false"
@update:model-value="rrule => updateRecurrence(bookingOverlay.job, rrule)"
/>
</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Fin de récurrence</span>
<input type="date" class="sb-form-input" :value="bookingOverlay.job?.recurrenceEnd || ''" @change="updateRecurrenceEnd(bookingOverlay.job, $event.target.value)" />
</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Pauses (ex: hiver)</span>
<div v-for="(p, idx) in (bookingOverlay.job?.pausePeriods||[])" :key="idx" style="display:flex;gap:4px;align-items:center;margin-top:3px">
<input type="date" class="sb-form-input" style="width:120px" :value="p.from" @change="updatePausePeriod(bookingOverlay.job, idx, 'from', $event.target.value)" />
<span style="font-size:0.7rem;color:#9ca3af">→</span>
<input type="date" class="sb-form-input" style="width:120px" :value="p.until" @change="updatePausePeriod(bookingOverlay.job, idx, 'until', $event.target.value)" />
<button style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem" @click="removePausePeriod(bookingOverlay.job, idx)">✕</button>
</div>
<button class="sb-rp-btn" style="margin-top:4px;font-size:0.68rem;padding:2px 8px" @click="addPausePeriod(bookingOverlay.job)">+ Ajouter une pause</button>
</div>
</template>
</div>
<div class="sb-rp-actions">
<button v-if="bookingOverlay.ghostDate" class="sb-rp-primary" @click="materializeGhost(bookingOverlay.job, bookingOverlay.ghostDate, bookingOverlay.tech?.id); bookingOverlay=null">✅ Matérialiser pour le {{ bookingOverlay.ghostDate }}</button>
<button v-else 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"
:col-w="calColW"
@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"
@ghost-click="onGhostClick" @ghost-materialize="materializeGhost"
@open-absence="openAbsenceModal" @end-absence="endAbsence"
@open-schedule="openScheduleModal" />
<MonthCalendar v-else-if="currentView==='month'"
:anchor-date="anchorDate" :filtered-resources="filteredResources" :today-str="todayStr"
:selected-tech-id="selectedTechId"
@go-to-day="goToDay" @select-tech="selectTechOnBoard" @open-schedule="openScheduleModal" />
<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" :class="{ 'sb-day-boundary': tick.isDay }" :style="'left:'+tick.x+'px'">
<span v-if="tick.isDay && dayColumns.length > 1" class="sb-day-lbl" :class="{ 'sb-day-today': localDateStr(tick.day) === todayStr }">
{{ fmtDate(tick.day) }}
</span>
<span v-else-if="tick.label" class="sb-htick-lbl">{{ tick.label }}</span>
</div>
</div>
</div>
<div v-if="store.loading" class="sb-loading-row">Chargement…</div>
<div v-else-if="!filteredResources.length" class="sb-empty-row">
Aucune ressource. <button class="sbf-primary-btn" style="display:inline-block;margin-left:0.75rem" @click="clearFilters">Réinitialiser</button>
</div>
<TimelineRow v-for="tech in filteredResources" :key="tech.id"
:tech="tech" :segments="segmentsMap[tech.id] || []"
:hour-ticks="hourTicks" :total-w="totalW" :px-per-hr="pxPerHr"
:h-start="H_START" :h-end="H_END" :row-h="ROW_H"
:is-selected="selectedTechId===tech.id"
:is-elevated="techHasLinkedJob(tech)||techIsHovered(tech)"
:drop-ghost-x="dropGhost?.techId===tech.id ? dropGhost.x : null"
@select-tech="selectTechOnBoard" @ctx-tech="openTechCtx" @open-tech-tags="t => techTagModal = t"
@drag-tech-start="(e, tech) => { onTechReorderStart(e, tech); onTechDragStart(e, tech) }" @reorder-drop="onTechReorderDrop"
@timeline-dragover="onTimelineDragOver" @timeline-dragleave="onTimelineDragLeave"
@timeline-drop="onTimelineDrop"
@job-dragstart="onJobDragStart" @job-click="selectJob"
@job-dblclick="openEditModal" @job-ctx="openCtxMenu"
@assist-ctx="openAssistCtx"
@hover-job="id => hoveredJobId=id" @unhover-job="hoveredJobId=null"
@block-move="startBlockMove" @block-resize="startResize"
@absence-resize="startAbsenceResize"
@ghost-click="onGhostClick" @ghost-materialize="materializeGhost"
@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) }" />
<!-- Offer pool slide-in panel -->
<transition name="sb-slide-right">
<div v-if="showOfferPool" class="sb-offer-pool-col">
<OfferPoolPanel
:offers="offers" :loading="loadingOffers"
@refresh="loadOffers"
@close="showOfferPool=false"
@offer-job="createOfferPrefill=null; createOfferModal=true"
@accept="o => onOfferAccept(o)"
@cancel="o => handleCancel(o.id)"
/>
</div>
</transition>
</div>
<!-- Context menus -->
<SbContextMenu :pos="ctxMenu">
<button class="sb-ctx-item" @click="ctxDetails()">📄 Voir détails</button>
<button class="sb-ctx-item" @click="ctxMove()">↔ Déplacer / Réassigner</button>
<button class="sb-ctx-item" @click="openTimeModal(ctxMenu.job, ctxMenu.techId); closeCtxMenu()">🕐 Fixer l'heure</button>
<button class="sb-ctx-item" @click="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button>
<button class="sb-ctx-item" @click="offerUnassignedJob(ctxMenu.job); closeCtxMenu()">📡 Offrir aux ressources</button>
<div class="sb-ctx-sep"></div>
<button class="sb-ctx-item sb-ctx-warn" @click="ctxUnschedule()">✕ Désaffecter</button>
</SbContextMenu>
<SbContextMenu :pos="techCtx">
<button class="sb-ctx-item" @click="selectTechOnBoard(techCtx.tech); techCtx=null">🗺 Voir sur la carte</button>
<button class="sb-ctx-item" @click="optimizeRoute()">🔀 Optimiser la route</button>
<button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button>
<button class="sb-ctx-item" @click="copyIcalUrl(techCtx.tech); techCtx=null">📅 Copier le lien iCal</button>
<div class="sb-ctx-sep"></div>
<button class="sb-ctx-item" @click="openTechHomeDialog(techCtx.tech); techCtx=null">📍 Adresse de départ…</button>
<button class="sb-ctx-item" @click="startTechGeoFix(techCtx.tech); techCtx=null">🎯 Choisir sur la carte</button>
<div class="sb-ctx-sep"></div>
<button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null">↗ Ouvrir dans ERPNext</button>
</SbContextMenu>
<!-- Banner shown while in tech-home pick mode. ESC cancels. -->
<div v-if="geoFixTech" class="sb-geofix-banner">
🎯 Cliquez sur la carte pour définir le point de départ de
<b>{{ geoFixTech.fullName }}</b>
<button class="sb-geofix-cancel" @click="cancelTechGeoFix()">Annuler</button>
</div>
<SbContextMenu :pos="assistCtx">
<button class="sb-ctx-item" @click="assistCtxTogglePin()">
{{ assistCtx?.job?.assistants?.find(a=>a.techId===assistCtx?.techId)?.pinned ? '↕ Rendre flottant' : '📌 Prioriser dans le timeline' }}
</button>
<button class="sb-ctx-item" @click="assistCtxNote()">📝 Modifier la note</button>
<button class="sb-ctx-item" @click="bookingOverlay={job:assistCtx.job, tech:store.technicians.find(t=>t.id===assistCtx.job.assignedTech)}; assistCtx=null">📄 Voir le job parent</button>
<div class="sb-ctx-sep"></div>
<button class="sb-ctx-item sb-ctx-warn" @click="assistCtxRemove()">✕ Retirer l'assistant</button>
</SbContextMenu>
<transition name="sb-slide-up">
<div v-if="multiSelect.length" class="sb-multi-bar">
<span class="sb-multi-count">{{ multiSelect.length }} sélectionné{{ multiSelect.length>1?'s':'' }}</span>
<button class="sb-multi-btn" @click="batchUnassign(pushUndo)">✕ Désaffecter</button>
<span class="sb-multi-sep">|</span>
<span class="sb-multi-lbl">Déplacer vers :</span>
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech" :style="'border-color:'+TECH_COLORS[t.colorIdx]" @click="batchMoveTo(t.id, localDateStr(periodStart), pushUndo)">
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase() }}
</button>
<button class="sb-multi-btn sb-multi-clear" @click="multiSelect=[]; selectedJob=null">Annuler</button>
</div>
</transition>
<!-- Tech tags modal -->
<SbModal :model-value="!!techTagModal" @update:model-value="v => { if(!v) techTagModal=null }" modal-class="sb-modal-tags" body-style="overflow:visible;min-height:320px">
<template #header><span>🏷 Tags — {{ techTagModal?.fullName }}</span></template>
<TagEditor v-if="techTagModal" :model-value="techTagModal.tagsWithLevel || []" @update:model-value="v => { techTagModal.tagsWithLevel = v; techTagModal.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistTechTags(techTagModal) }"
:all-tags="store.allTags" :get-color="getTagColor" :show-level="true"
level-label="Compétence" level-hint="1 = base · 5 = expert"
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
<template #footer><button class="sb-rp-primary" @click="techTagModal=null">Fermer</button></template>
</SbModal>
<!-- Assistant note modal -->
<SbModal :model-value="!!assistNoteModal" @update:model-value="v => { if(!v) assistNoteModal=null }">
<template #header><span>📝 Note assistant</span></template>
<template v-if="assistNoteModal">
<label class="sb-form-lbl">Titre / note pour cette tâche</label>
<input type="text" class="sb-form-input" v-model="assistNoteModal.note" placeholder="Ex: Livraison outil, Support câblage..." @keyup.enter="confirmAssistNote" />
<p style="font-size:0.6rem;color:#7b80a0;margin-top:0.3rem">Job parent : {{ assistNoteModal.job?.subject }}</p>
</template>
<template #footer><button class="sb-rp-primary" @click="confirmAssistNote">Enregistrer</button><button class="sb-rp-btn" @click="assistNoteModal=null">Annuler</button></template>
</SbModal>
<!-- Time pin modal -->
<SbModal :model-value="!!timeModal" @update:model-value="v => { if(!v) timeModal=null }">
<template #header><span>🕐 Heure de début fixe</span></template>
<template v-if="timeModal">
<div class="sb-form-row"><label class="sb-form-lbl">Job</label><div class="sb-form-val">{{ timeModal.job?.subject }}</div></div>
<div class="sb-form-row"><label class="sb-form-lbl">Heure fixe</label><input type="time" class="sb-form-input" v-model="timeModal.time" /></div>
<div v-if="timeModal.hasPin" style="font-size:0.68rem;color:#f59e0b;margin-top:0.4rem">⚠ Heure actuellement fixée — modifier ou supprimer ci-dessous.</div>
</template>
<template #footer>
<button class="sbf-primary-btn" @click="confirmTime">📌 Fixer</button>
<button v-if="timeModal?.hasPin" class="sb-rp-btn" style="color:#ef4444" @click="clearTime">✕ Supprimer</button>
<button class="sb-rp-btn" @click="timeModal=null">Annuler</button>
</template>
</SbModal>
<!-- Move modal -->
<SbModal v-model="moveModalOpen">
<template #header><span>Déplacer la réservation</span></template>
<template v-if="moveForm">
<div class="sb-form-row"><label class="sb-form-lbl">Ticket</label><div class="sb-form-val">{{ moveForm.job?.subject }}</div></div>
<div class="sb-form-row"><label class="sb-form-lbl">Technicien actuel</label><div class="sb-form-val">{{ store.technicians.find(t=>t.id===moveForm.srcTechId)?.fullName || '—' }}</div></div>
<div class="sb-form-row"><label class="sb-form-lbl">Nouveau technicien</label>
<select class="sb-form-sel" v-model="moveForm.newTechId"><option v-for="t in store.technicians" :key="t.id" :value="t.id">{{ t.fullName }}</option></select>
</div>
<div class="sb-form-row"><label class="sb-form-lbl">Nouvelle date</label><input type="date" class="sb-form-input" v-model="moveForm.newDate" /></div>
</template>
<template #footer><button class="sbf-primary-btn" @click="confirmMove">✓ Confirmer</button><button class="sb-rp-btn" @click="moveModalOpen=false">Annuler</button></template>
</SbModal>
<!-- Resource selector modal -->
<SbModal :model-value="resSelectorOpen" @update:model-value="v => resSelectorOpen=v" :wide="true">
<template #header><span>Ressources & Groupes</span></template>
<div v-if="savedPresets.length" class="sb-rsel-groups">
<div class="sb-rsel-section-title">Sélections sauvegardées</div>
<div class="sb-rsel-chips">
<button v-for="(p, idx) in savedPresets" :key="p.name+'-'+idx" class="sb-rsel-chip sb-rsel-preset"
:class="{ active: tempSelectedIds.length && p.ids.length === tempSelectedIds.length && p.ids.every(id => tempSelectedIds.includes(id)), 'sb-rsel-preset-group': p.type === 'group' }"
@click="loadPreset(p)">
<span v-if="p.type === 'group'" class="sb-rsel-preset-icon">👥</span>
{{ p.name }} <span class="sb-rsel-preset-count">{{ p.type === 'group' ? store.technicians.filter(t => t.group === p.group && t.status !== 'inactive').length : p.ids.length }}</span>
<span class="sb-rsel-preset-del" @click.stop="deletePreset(idx)" title="Supprimer">✕</span>
</button>
</div>
</div>
<div class="sb-rsel-groups">
<div class="sb-rsel-section-title">Groupes</div>
<div class="sb-rsel-chips">
<button class="sb-rsel-chip" :class="{ active: !resSelectorGroupFilter }" @click="resSelectorGroupFilter=''">Tous</button>
<span v-for="g in availableGroups" :key="g" class="sb-rsel-group-wrap">
<button class="sb-rsel-chip"
:class="{ active: resSelectorGroupFilter === g }" @click="resSelectorGroupFilter = resSelectorGroupFilter === g ? '' : g">{{ g }}
<span class="sb-rsel-group-count">{{ store.technicians.filter(t => t.group === g && t.status !== 'inactive').length }}</span>
</button>
<button v-if="!savedPresets.some(p => p.type === 'group' && p.group === g)"
class="sb-rsel-save-group" @click.stop="saveGroupAsPreset(g)" title="Sauvegarder ce groupe">💾</button>
</span>
</div>
<div class="sb-rsel-group-actions">
<button v-if="resSelectorGroupFilter" class="sb-rsel-apply-group" @click="applyGroupFilter">
Afficher seulement « {{ resSelectorGroupFilter }} »
</button>
</div>
</div>
<div class="sb-rsel-search-row">
<input v-model="resSelectorSearch" class="sb-rsel-search" placeholder="🔍 Rechercher une ressource…" />
</div>
<div class="sb-res-sel-wrap">
<div class="sb-res-sel-col">
<div class="sb-res-sel-hdr">Disponibles</div>
<template v-for="group in resSelectorGroupsFiltered.available" :key="'avail-'+group.name">
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }} <span class="sbf-count">{{ group.techs.length }}</span></div>
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item" @click="toggleTempRes(t.id)">
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
<span class="sb-res-sel-name">{{ t.fullName }}</span>
<span v-if="t.resourceCategory" class="sb-res-sel-cat-tag">{{ t.resourceCategory }}</span>
<span v-else-if="t.group" class="sb-res-sel-grp-tag">{{ t.group }}</span>
</div>
</template>
<div v-if="!resSelectorGroupsFiltered.available.flatMap(g=>g.techs).length" class="sbf-empty">Toutes sélectionnées</div>
</div>
<div class="sb-res-sel-arrow">→</div>
<div class="sb-res-sel-col">
<div class="sb-res-sel-hdr">Sélectionnées <span class="sbf-count">{{ tempSelectedIds.length || 'Toutes' }}</span></div>
<template v-for="group in resSelectorGroupsFiltered.selected" :key="'sel-'+group.name">
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }}</div>
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item sb-res-sel-active" @click="toggleTempRes(t.id)">
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
<span class="sb-res-sel-name">{{ t.fullName }}</span><span class="sb-res-sel-rm">✕</span>
</div>
</template>
<div v-if="!tempSelectedIds.length" class="sbf-empty">Toutes affichées</div>
</div>
</div>
<template #footer>
<button class="sbf-primary-btn" @click="applyResSelector">Appliquer</button>
<template v-if="tempSelectedIds.length">
<button v-if="!showPresetSave" class="sb-rp-btn" @click="showPresetSave=true">💾 Sauvegarder</button>
<div v-else class="sb-rsel-save-row">
<input v-model="presetNameInput" class="sb-rsel-save-input" placeholder="Nom du groupe…"
@keyup.enter="savePreset" @keyup.escape="showPresetSave=false" />
<button class="sb-rsel-save-btn" @click="savePreset" :disabled="!presetNameInput.trim()">✓</button>
<button class="sb-rp-btn" style="padding:0.2rem 0.4rem" @click="showPresetSave=false">✕</button>
</div>
<button class="sb-rp-btn" @click="tempSelectedIds=[]">Tout désélectionner</button>
</template>
<button class="sb-rp-btn" @click="resSelectorOpen=false">Annuler</button>
</template>
</SbModal>
<UnifiedCreateModal v-model="woModalOpen" mode="work-order"
:context="woModalCtx"
:technicians="store.technicians"
:external-tags="store.allTags"
:external-get-color="getTagColor"
:submit-handler="confirmWo" />
<JobEditModal v-model="editModal" @confirm="confirmEdit" />
<PublishScheduleModal v-model="publishModalOpen"
:jobs="store.jobs" :technicians="filteredResources"
:period-start="periodStart" :period-end="periodEndStr"
@published="onPublished" />
<!-- Dispatch criteria modal -->
<SbModal :model-value="dispatchCriteriaModal" @update:model-value="v => dispatchCriteriaModal=v">
<template #header><span>⚙ Critères de dispatch automatique</span></template>
<p style="font-size:0.65rem;color:var(--sb-muted);margin:0 0 0.5rem">Glissez pour réordonner. Les critères du haut ont plus de poids.</p>
<div v-for="(c, i) in dispatchCriteria" :key="c.id" class="sb-crit-row"
draggable="true"
:class="{ 'sb-crit-drag-over': critDragOver === i }"
@dragstart="critDragIdx = i; $event.dataTransfer.effectAllowed = 'move'"
@dragend="critDragIdx = null; critDragOver = null"
@dragover.prevent="critDragOver = i"
@dragleave="critDragOver === i && (critDragOver = null)"
@drop.prevent="dropCriterion(i)">
<span class="sb-crit-handle" title="Glisser">⠿</span>
<span class="sb-crit-order">{{ i + 1 }}</span>
<label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label>
<div class="sb-crit-arrows">
<button :disabled="i===0" @click="moveCriterion(i,-1)">▲</button>
<button :disabled="i===dispatchCriteria.length-1" @click="moveCriterion(i,1)">▼</button>
</div>
</div>
<template #footer><button class="sbf-primary-btn" @click="saveDispatchCriteria">✓ Enregistrer</button><button class="sb-rp-btn" @click="dispatchCriteriaModal=false">Annuler</button></template>
</SbModal>
<!-- GPS settings modal (custom structure, not using SbModal) -->
<div v-if="gpsSettingsOpen" class="sb-modal-overlay" @click.self="gpsSettingsOpen=false">
<div class="sb-gps-modal">
<div class="sb-gps-modal-hdr">
<h3>📡 GPS Tracking — Traccar</h3>
<button class="sb-gps-close" @click="gpsSettingsOpen=false">&times;</button>
</div>
<div class="sb-gps-modal-body">
<p class="sb-gps-desc">Gérer les ressources dispatch, associer les devices Traccar et suivre le GPS en temps réel.</p>
<div class="sb-gps-toggle-row">
<label class="sb-gps-toggle-label">
<input type="checkbox" v-model="gpsShowInactive" />
Afficher les inactifs
<span v-if="inactiveCount" class="sb-gps-inactive-count">({{ inactiveCount }})</span>
</label>
</div>
<table class="sb-gps-table">
<thead><tr><th>Technicien</th><th>Groupe</th><th>Téléphone</th><th>Statut</th><th>Device GPS</th><th>GPS</th><th></th></tr></thead>
<tbody>
<tr v-for="tech in gpsFilteredTechs" :key="tech.id" :class="{ 'sb-gps-inactive-row': !tech.active }">
<td>
<input v-if="editingTech === tech.id" class="sb-gps-input sb-gps-edit-name" :value="tech.fullName"
@blur="saveTechField(tech, 'full_name', $event.target.value); editingTech = null"
@keydown.enter="$event.target.blur()" @keydown.escape="editingTech = null" />
<strong v-else @dblclick="editingTech = tech.id" class="sb-gps-editable" title="Double-clic pour modifier">{{ tech.fullName }}</strong>
</td>
<td>
<input class="sb-gps-input" :value="tech.group" placeholder="Ex: Terrain, Support..." style="width:100px"
@blur="saveTechGroup(tech, $event.target.value)" @keydown.enter="$event.target.blur()" list="tech-groups" />
</td>
<td><input class="sb-gps-input sb-gps-phone" :value="tech.phone" placeholder="514..." @blur="saveTechField(tech, 'phone', $event.target.value)" @keydown.enter="$event.target.blur()" /></td>
<td>
<select :value="tech.status || 'available'" @change="onTechStatusChange(tech, $event.target.value)" class="sb-gps-select sb-gps-status-sel">
<option v-for="s in [['available','Disponible'],['busy','Occupé'],['off','Absent'],['inactive','Inactif']]" :key="s[0]" :value="s[0]">{{ s[1] }}</option>
</select>
<div v-if="tech.absenceReason && !tech.active" class="sb-gps-absence-info">
{{ ABSENCE_REASONS.find(r => r.value === tech.absenceReason)?.icon || '📋' }}
{{ ABSENCE_REASONS.find(r => r.value === tech.absenceReason)?.label || tech.absenceReason }}
<span v-if="tech.absenceUntil" class="sb-gps-absence-date">→ {{ tech.absenceUntil }}</span>
</div>
</td>
<td>
<select :value="tech.traccarDeviceId || ''" @change="saveTraccarLink(tech, $event.target.value)" class="sb-gps-select">
<option value="">— Non lié —</option>
<option v-for="d in store.traccarDevices" :key="d.id" :value="String(d.id)">{{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }}</option>
</select>
</td>
<td>
<span v-if="tech.gpsCoords" class="sb-gps-badge sb-gps-online" :title="tech.gpsCoords[1].toFixed(4)+', '+tech.gpsCoords[0].toFixed(4)">
En ligne<span v-if="tech.gpsSpeed > 1"> · {{ (tech.gpsSpeed * 1.852).toFixed(0) }}km/h</span>
</span>
<span v-else-if="tech.traccarDeviceId" class="sb-gps-badge sb-gps-offline">Hors ligne</span>
<span v-else class="sb-gps-badge sb-gps-none">—</span>
</td>
<td class="sb-gps-actions">
<button class="sb-gps-loc-btn" @click="openTechHomeDialog(tech)" title="Modifier l'adresse de départ">📍</button>
<button v-if="tech.active" class="sb-gps-absence-btn" @click="openAbsenceModal(tech)" title="Mettre en absence">⏸</button>
<button v-else class="sb-gps-react-btn" @click="endAbsence(tech)" title="Réactiver">▶</button>
</td>
</tr>
<tr class="sb-gps-add-row">
<td><input v-model="newTechName" class="sb-gps-input" placeholder="Nom complet" @keydown.enter="addTech({ tech_group: newTechGroup.trim() || '' }); newTechGroup = ''" /></td>
<td><input v-model="newTechGroup" class="sb-gps-input" placeholder="Groupe..." list="tech-groups" style="width:100px" /></td>
<td><input v-model="newTechPhone" class="sb-gps-input sb-gps-phone" placeholder="514..." /></td>
<td></td>
<td>
<select v-model="newTechDevice" class="sb-gps-select">
<option value="">— Aucun —</option>
<option v-for="d in store.traccarDevices" :key="d.id" :value="String(d.id)">{{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }}</option>
</select>
</td>
<td colspan="2">
<button class="sb-gps-add-btn" @click="addTech({ tech_group: newTechGroup.trim() || '' }); newTechGroup = ''" :disabled="!newTechName.trim() || addingTech">{{ addingTech ? '...' : '+ Ajouter' }}</button>
</td>
</tr>
</tbody>
</table>
<datalist id="tech-groups">
<option v-for="g in availableGroups" :key="g" :value="g" />
</datalist>
<div class="sb-gps-footer">
<span class="sb-gps-info">{{ store.traccarDevices.length }} devices Traccar · {{ store.technicians.length }} techniciens · {{ availableGroups.length }} groupes</span>
</div>
</div>
</div>
</div>
<!-- Absence modal -->
<SbModal :model-value="absenceModalOpen" @update:model-value="v => absenceModalOpen=v" overlay-class="sb-overlay-top" modal-class="sb-absence-modal">
<template #header><span>⏸ Mettre en absence — {{ absenceModalTech?.fullName }}</span></template>
<div class="sb-absence-form">
<label class="sb-absence-lbl">Raison</label>
<div class="sb-absence-reasons">
<button v-for="r in ABSENCE_REASONS" :key="r.value"
class="sb-absence-reason-btn" :class="{ active: absenceForm.reason === r.value }"
@click="absenceForm.reason = r.value">
{{ r.icon }} {{ r.label }}
</button>
</div>
<div class="sb-absence-dates">
<div>
<label class="sb-absence-lbl">Du</label>
<input type="date" class="sb-form-input" v-model="absenceForm.from" />
</div>
<div>
<label class="sb-absence-lbl">Jusqu'au <span class="sb-absence-opt">(optionnel)</span></label>
<input type="date" class="sb-form-input" v-model="absenceForm.until" />
</div>
</div>
<div v-if="store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length" class="sb-absence-jobs">
<label class="sb-absence-lbl">
{{ store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length }} job(s) assigné(s)
</label>
<div class="sb-absence-job-actions">
<label class="sb-absence-radio">
<input type="radio" v-model="absenceForm.jobAction" value="unassign" />
Désassigner (retour dans le pool non assigné)
</label>
<label class="sb-absence-radio">
<input type="radio" v-model="absenceForm.jobAction" value="keep" />
Garder assignés (réassigner manuellement après)
</label>
</div>
<div class="sb-absence-job-list">
<div v-for="j in store.jobs.filter(j => j.assignedTech === absenceModalTech?.id)" :key="j.id" class="sb-absence-job-item">
<span class="sb-absence-job-dot" :style="{ background: j.priority === 'urgent' ? '#ef4444' : j.priority === 'high' ? '#f59e0b' : '#6366f1' }"></span>
{{ j.subject }} <span class="sb-absence-job-date">{{ j.scheduledDate || '—' }}</span>
</div>
</div>
</div>
<div v-else class="sb-absence-no-jobs">Aucun job assigné actuellement.</div>
</div>
<template #footer>
<button class="sbf-primary-btn" @click="confirmAbsence" :disabled="absenceProcessing">
{{ absenceProcessing ? 'En cours...' : ' Confirmer l\'absence' }}
</button>
<button class="sb-rp-btn" @click="absenceModalOpen = false">Annuler</button>
</template>
</SbModal>
<!-- Schedule editor modal -->
<div v-if="scheduleModalTech" class="sb-overlay sb-overlay-top" @click.self="scheduleModalTech = null">
<div class="sb-modal sb-schedule-modal">
<div class="sb-modal-hdr">
<span>🗓 Horaire — {{ scheduleModalTech.fullName }}</span>
<button class="sb-rp-close" @click="scheduleModalTech = null">✕</button>
</div>
<div class="sb-schedule-presets">
<button v-for="p in SCHEDULE_PRESETS" :key="p.key" class="sb-preset-btn" @click="applySchedulePreset(p)">{{ p.label }}</button>
</div>
<div class="sb-schedule-grid">
<div v-for="d in WEEK_DAYS" :key="d" class="sb-schedule-day" :class="{ 'sb-schedule-off': !scheduleForm[d]?.on }">
<label class="sb-schedule-toggle">
<input type="checkbox" v-model="scheduleForm[d].on" />
<span class="sb-schedule-day-label">{{ DAY_LABELS[d] }}</span>
</label>
<template v-if="scheduleForm[d]?.on">
<input type="time" v-model="scheduleForm[d].start" class="sb-schedule-time" />
<span class="sb-schedule-sep">→</span>
<input type="time" v-model="scheduleForm[d].end" class="sb-schedule-time" />
<span class="sb-schedule-hours">{{ ((timeToH(scheduleForm[d].end) - timeToH(scheduleForm[d].start)) || 0).toFixed(1) }}h</span>
</template>
<span v-else class="sb-schedule-off-label">Repos</span>
</div>
</div>
<!-- On-call / garde shifts -->
<div class="sb-extra-shifts-section">
<div class="sb-extra-shifts-hdr">
<span>🔔 Shifts de garde / urgence</span>
<button class="sb-rp-btn sb-rp-btn-sm" @click="addExtraShift">+ Ajouter</button>
</div>
<div v-if="!extraShiftsForm.length" class="sb-extra-shifts-empty">
Aucun shift de garde. Ajoutez-en pour planifier les disponibilités hors-horaire.
</div>
<div v-for="(shift, idx) in extraShiftsForm" :key="idx" class="sb-extra-shift-row">
<input v-model="shift.label" class="sb-extra-shift-label" placeholder="Label (ex: Garde)" />
<input type="time" v-model="shift.startTime" class="sb-schedule-time" />
<span class="sb-schedule-sep">→</span>
<input type="time" v-model="shift.endTime" class="sb-schedule-time" />
<div class="sb-extra-shift-recurrence">
<RecurrenceSelector
:model-value="shift.rrule || ''"
:ref-date="shift.from || todayStr"
:show-none="false"
@update:model-value="rrule => { shift.rrule = rrule; const p = _parseShiftPattern(rrule); shift._pattern = p.pattern; shift._interval = p.interval }"
/>
</div>
<input type="date" v-model="shift.from" class="sb-schedule-time" title="Début de la récurrence" />
<button class="sb-extra-shift-del" @click="removeExtraShift(idx)" title="Supprimer">✕</button>
</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>
<!-- Confirm Unassign Dialog -->
<div v-if="confirmUnassignDialog" class="sb-modal-overlay" @click.self="cancelUnassign">
<div class="sb-confirm-dialog">
<div class="sb-confirm-icon">⚠️</div>
<div class="sb-confirm-title">Désaffecter ce job ?</div>
<div class="sb-confirm-body">
<strong>{{ pendingUnassignJob?.subject || pendingUnassignJob?.id }}</strong><br>
<span v-if="pendingUnassignJob?.published" class="sb-confirm-tag sb-confirm-tag-pub">Publié</span>
<span v-if="pendingUnassignJob?.status === 'in_progress' || pendingUnassignJob?.status === 'In Progress'" class="sb-confirm-tag sb-confirm-tag-ip">En cours</span>
<span v-if="pendingUnassignJob?.status === 'assigned'" class="sb-confirm-tag sb-confirm-tag-asg">Assigné</span>
<br><span class="sb-confirm-warn">Le technicien a déjà reçu cette tâche. Désaffecter la remettra dans le pool non-assigné.</span>
</div>
<div class="sb-confirm-actions">
<button class="sb-rp-btn" @click="cancelUnassign">Annuler</button>
<button class="sb-rp-btn sb-confirm-danger" @click="confirmUnassign">Désaffecter</button>
</div>
</div>
</div>
<!-- Create Offer Modal -->
<CreateOfferModal
v-model="createOfferModal"
:technicians="store.technicians"
:all-tags="store.allTags"
:prefill="createOfferPrefill"
@create="onCreateOffer"
/>
</div>
</template>
<style src="./dispatch-styles.scss"></style>