<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
import { useRoute } from 'vue-router'
import { Notify } from 'quasar'
import * as roster from 'src/api/roster'
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 SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.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 route = useRoute()
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
}

// #58 — Désaffecter + AVISER LE CLIENT : le hub /roster/job/notify-reschedule désassigne côté serveur
// (booking_status « À reporter », vide le créneau), retrouve le mobile du Customer et envoie un SMS avec
// un lien /book pour rechoisir un créneau. On reflète ensuite le désassignement dans la vue locale.
const notifyingClient = ref(false)
async function unassignAndNotify (job) {
  if (!job || notifyingClient.value) return
  notifyingClient.value = true
  let r
  try { r = await roster.notifyReschedule({ job: job.name || job.id }) }
  catch (e) { Notify.create({ type: 'negative', message: 'Échec de la notification : ' + (e.message || e) }); notifyingClient.value = false; return }
  store.fullUnassign(job.id) // met à jour la vue (le serveur a déjà désassigné — réécriture idempotente)
  const j = store.jobs.find(x => x.id === job.id); if (j) { j.scheduledDate = null; j.startTime = null }
  if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
  invalidateRoutes()
  Notify.create({ type: r && r.sms ? 'positive' : 'warning', timeout: 5000,
    message: r && r.sms ? ('Client avisé par SMS' + (r.phone ? ' (' + r.phone + ')' : '') + ' · job remis au pool « À reporter »')
                        : ('Désaffecté · ' + ((r && (r.note || r.error)) || 'SMS non envoyé (aucun mobile au dossier)')) })
  notifyingClient.value = false
  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, bottomSort, 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 showSuggestSlots = ref(false)
const bookingSlot = ref(null) // créneau choisi (porte start_time + coords) — injecté dans confirmWo
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 resTypeMenuOpen = ref(false) // Resource type chip dropdown ([👤 45 ▾])
const resTypeIcon = computed(() =>
  filterResourceType.value === 'material' ? ICON.package
  : filterResourceType.value === 'human'   ? ICON.users
  : ICON.users   // 'Tous' falls back to the people icon
)
const resTypeLabel = computed(() =>
  filterResourceType.value === 'material' ? 'Matériel'
  : filterResourceType.value === 'human'   ? 'Techs'
  : 'Toutes les ressources'
)
const resTypeCount = computed(() =>
  filterResourceType.value === 'material' ? materialCount.value
  : filterResourceType.value === 'human'   ? humanCount.value
  : (humanCount.value + materialCount.value)
)
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) {
  bookingSlot.value = null // création manuelle → pas de créneau réservé
  woModalCtx.value = { scheduled_date: prefillDate || todayStr, assigned_tech: prefillTech || null }
  woModalOpen.value = true
}
// P1-C : un créneau choisi dans « Trouver un créneau » → pré-remplit la création de job (tech + date + adresse), l'heure/coords injectées au submit.
function onSlotSelected (slot) {
  bookingSlot.value = slot
  woModalCtx.value = { scheduled_date: slot.date, assigned_tech: slot.tech_id, address: slot._address || '', duration_h: slot._duration || 1 }
  woModalOpen.value = true
}
async function confirmWo (formData) {
  const slot = bookingSlot.value // réservation via « Trouver un créneau » → heure + coords géocodées de l'adresse
  const r = 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,
    start_time: slot ? slot.start_time : null, // heure du créneau réservé (sinon non planifié à l'heure)
    latitude: formData._latitude || (slot ? slot._latitude : null),
    longitude: formData._longitude || (slot ? slot._longitude : null),
    note: formData.description || '',
    tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t),
    depends_on: formData.depends_on || '',
  })
  bookingSlot.value = null
  return r
}

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
    resTypeMenuOpen.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; resTypeMenuOpen.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)
  // Deep-link depuis la Planification : ?tech=<id>&date=<YYYY-MM-DD> → focus jour + ressource.
  // 'T12:00:00' = midi local pour éviter le décalage de jour (new Date('YYYY-MM-DD') = UTC).
  nextTick(() => {
    try {
      const q = route.query
      if (q.date) goToDay(String(q.date) + 'T12:00:00')
      if (q.tech) { const tk = store.technicians.find(t => t.id === String(q.tech)); if (tk && selectedTechId.value !== tk.id) selectTechOnBoard(tk) }
    } catch (_) {}
  })
})

// 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>
      <!-- Resource type — single chip with Lucide icon + count + chevron.
           Default is 'human' (techs are the primary thing); materials
           are accessed via the dropdown only when needed (and aren't
           even shown if the customer doesn't have any). -->
      <div class="sb-menu-wrap">
        <button class="sb-icon-btn sb-tab-current" :class="{ active: resTypeMenuOpen }"
          @click.stop="resTypeMenuOpen = !resTypeMenuOpen"
          :title="resTypeLabel">
          <span class="sb-icon-svg" v-html="resTypeIcon"></span>
          <span class="sbf-count">{{ resTypeCount }}</span>
          <span class="sb-tab-chev" v-html="ICON.chevDown"></span>
        </button>
        <div v-if="resTypeMenuOpen" class="sb-menu-dropdown sb-menu-dropdown-left" @click.stop>
          <button class="sb-menu-item" :class="{ active: filterResourceType === 'human' }" @click="filterResourceType='human'; resTypeMenuOpen=false">
            <span class="sb-icon-svg" v-html="ICON.users"></span> Techs
            <span class="sbs-count" style="margin-left:auto">{{ humanCount }}</span>
          </button>
          <button v-if="materialCount > 0" class="sb-menu-item" :class="{ active: filterResourceType === 'material' }" @click="filterResourceType='material'; resTypeMenuOpen=false">
            <span class="sb-icon-svg" v-html="ICON.package"></span> Matériel
            <span class="sbs-count" style="margin-left:auto">{{ materialCount }}</span>
          </button>
          <div v-if="materialCount > 0" class="sb-menu-sep"></div>
          <button v-if="materialCount > 0" class="sb-menu-item" :class="{ active: !filterResourceType }" @click="filterResourceType=''; resTypeMenuOpen=false">
            Tous
            <span class="sbs-count" style="margin-left:auto">{{ humanCount + materialCount }}</span>
          </button>
        </div>
      </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 &amp; Ressources">
        <!-- Settings/filter icon (sliders, not wrench) — `wrench` is now
             reserved for the materials filter to avoid the visual clash
             where two distinct concerns shared the same wrench glyph. -->
        <span class="sb-icon-svg" v-html="ICON.sliders"></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="sb-icon-svg" v-html="ICON.users"></span>
        <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 class="sb-icon-svg" v-html="ICON.calendar"></span>
        <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(', ')">
        <span class="sb-icon-svg" v-html="ICON.alertTri"></span>
        {{ 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 class="sb-icon-svg" v-html="ICON.clipboard"></span>
        <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">
        <span class="sb-icon-svg" v-html="ICON.map"></span>
        <span>Carte</span>
      </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="showSuggestSlots = true" title="Trouver le meilleur créneau (tech + date + heure) pour un nouveau job" style="margin-right:6px">📅 Créneau</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">
          <span class="sb-icon-svg" v-html="ICON.moreH"></span>
        </button>
        <div v-if="moreMenuOpen" class="sb-menu-dropdown" @click.stop>
          <button class="sb-menu-item" @click="refreshData(); moreMenuOpen=false">
            <span class="sb-icon-svg" v-html="ICON.rotateCw"></span> Actualiser
          </button>
          <button class="sb-menu-item" :class="{ active: nlpVisible }" @click="nlpVisible=!nlpVisible; moreMenuOpen=false">
            <span class="sb-icon-svg" v-html="ICON.sparkles"></span> Assistant IA
          </button>
          <button class="sb-menu-item" :class="{ active: showOfferPool }" @click="showOfferPool=!showOfferPool; if(showOfferPool) loadOffers(); moreMenuOpen=false">
            <span class="sb-icon-svg" v-html="ICON.signal"></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-icon-svg" v-html="ICON.users"></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-icon-svg" v-html="ICON.externalLink"></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"
                   :sort="bottomSort" @update:sort="v => bottomSort = v"
                   :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>
    <a v-if="ctxMenu?.job?.customer" class="sb-ctx-item sb-ctx-link"
      :href="'#/clients/' + ctxMenu.job.customer" @click="closeCtxMenu()">
      👤 Voir la fiche client ({{ ctxMenu.job.customer }})
    </a>
    <a v-if="ctxMenu?.job?.serviceLocation && ctxMenu?.job?.customer" class="sb-ctx-item sb-ctx-link"
      :href="'#/clients/' + ctxMenu.job.customer + '?location=' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()">
      🏠 Lieu de service ({{ ctxMenu.job.serviceLocation }})
    </a>
    <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('/app/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" />
  <!-- P1-C : « Trouver un créneau » — backend suggest-slots (gap-finding + route) → pré-remplit la création de job -->
  <SuggestSlotsDialog v-model="showSuggestSlots" @select="onSlotSelected" />
  <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" :disabled="notifyingClient" @click="unassignAndNotify(pendingUnassignJob)" title="Désaffecter + envoyer au client un SMS avec un lien pour choisir un nouveau créneau">{{ notifyingClient ? 'Envoi…' : '📩 Désaffecter + aviser le client' }}</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>
