gigafibre-fsm/apps/ops/src/pages/DispatchPage.vue
louispaulb 73691668d3 feat: tech mobile view integrated into ops app at /j, unassign confirmation
Tech mobile view (erp.gigafibre.ca/ops/#/j):
- TechLayout with bottom nav tabs (tasks, scanner, diagnostic, more)
- TechTasksPage: rich header with tech name/stats, job cards with
  priority dots, time, location, duration badges, bottom sheet detail
  with En route/Terminer buttons + scanner/detail access
- TechJobDetailPage: editable fields, equipment list, GPS navigation
- TechScanPage: device lookup by SN/MAC, create/link to job
- TechDiagnosticPage: speed test + host reachability checks
- Route /j replaces legacy dispatch-app tech view

Dispatch unassign confirmation:
- Dialog appears when unassigning published or in-progress jobs
- Warns that tech has already received the task
- Cancel/Confirm flow prevents accidental removal

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

1813 lines
92 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
import { Notify } from 'quasar'
import { useDispatchStore } from 'src/stores/dispatch'
import { useAuthStore } from 'src/stores/auth'
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
import { ROW_H, RES_ICONS, HUB_SSE_URL } from 'src/config/dispatch'
import { fetchOpenRequests } from 'src/api/service-request'
import { updateJob, updateTech } from 'src/api/dispatch'
import TagEditor from 'src/components/shared/TagEditor.vue'
import TimelineRow from 'src/modules/dispatch/components/TimelineRow.vue'
import BottomPanel from 'src/modules/dispatch/components/BottomPanel.vue'
import JobEditModal from 'src/modules/dispatch/components/JobEditModal.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import PublishScheduleModal from 'src/modules/dispatch/components/PublishScheduleModal.vue'
import WeekCalendar from 'src/modules/dispatch/components/WeekCalendar.vue'
import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue'
import RightPanel from 'src/modules/dispatch/components/RightPanel.vue'
import SbModal from 'src/modules/dispatch/components/SbModal.vue'
import SbContextMenu from 'src/modules/dispatch/components/SbContextMenu.vue'
import {
localDateStr, fmtDate, timeToH, hToTime, fmtDur,
SVC_COLORS, prioLabel, prioClass, serializeAssistants,
jobColor as _jobColorBase, ICON, prioColor,
WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS,
buildRRule,
} from 'src/composables/useHelpers'
import { useScheduler } from 'src/composables/useScheduler'
import { useUndo } from 'src/composables/useUndo'
import { useMap } from 'src/composables/useMap'
import { useBottomPanel } from 'src/composables/useBottomPanel'
import { useDragDrop } from 'src/composables/useDragDrop'
import { useSelection } from 'src/composables/useSelection'
import { useAutoDispatch } from 'src/composables/useAutoDispatch'
import { usePeriodNavigation } from 'src/composables/usePeriodNavigation'
import { useResourceFilter } from 'src/composables/useResourceFilter'
import { useTagManagement } from 'src/composables/useTagManagement'
import { useContextMenus } from 'src/composables/useContextMenus'
import { useTechManagement } from 'src/composables/useTechManagement'
import { useAddressSearch } from 'src/composables/useAddressSearch'
import { useAbsenceResize } from 'src/composables/useAbsenceResize'
import { useJobOffers } from 'src/composables/useJobOffers'
import { fetchPresets, createPreset as apiCreatePreset, deletePreset as apiDeletePreset } from 'src/api/presets'
import OfferPoolPanel from 'src/modules/dispatch/components/OfferPoolPanel.vue'
import CreateOfferModal from 'src/modules/dispatch/components/CreateOfferModal.vue'
import RecurrenceSelector from 'src/components/shared/RecurrenceSelector.vue'
const store = useDispatchStore()
const auth = useAuthStore()
const erpUrl = BASE_URL || window.location.origin
// Offer pool (Uber-style job offers)
const {
offers, loadingOffers, showOfferPool, activeOfferCount,
loadOffers, broadcastOffer, handleAccept, handleDecline, handleCancel,
matchingTechs, estimateCost, offerExistingJob,
} = useJobOffers(store)
const createOfferModal = ref(false)
const createOfferPrefill = ref(null)
const {
currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr,
bufferDaysBefore, renderedDays,
prevPeriod, nextPeriod, goToToday, goToDay,
} = usePeriodNavigation()
// Mutable opts populated after useScheduler is initialized (callbacks capture by ref)
const _resFilterOpts = {
isAbsentOnDay: null,
getLoadH: null,
}
const {
selectedResIds, filterStatus, filterTags, filterResourceType, searchQuery, techSort, manualOrder,
filteredResources, groupedResources, availableGroups, filterGroup,
showInactive, hideAbsent, inactiveCount, humanCount, materialCount, availableCategories,
resSelectorOpen, tempSelectedIds, dragReorderTech,
openResSelector, applyResSelector, toggleTempRes, clearFilters,
onTechReorderStart, onTechReorderDrop,
} = useResourceFilter(store, _resFilterOpts)
const techTagModal = ref(null)
const {
getTagColor, onCreateTag, onUpdateTag, onRenameTag, onDeleteTag,
_serializeTags, persistJobTags, persistTechTags,
} = useTagManagement(store)
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
const setEndDate = (job, endDate) => {
job.endDate = endDate || null
updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {})
}
const toggleContinuous = (job, val) => {
job.continuous = !!val
store.jobVersion++
updateJob(job.name || job.id, { continuous: val ? 1 : 0 }).catch(() => {})
}
const filterPanelOpen = ref(false)
const projectsPanelOpen = ref(false)
const planningMode = ref(false) // Toggle: show shift availability blocks on timeline
const mapVisible = ref(localStorage.getItem('sbv2-map') === 'true')
const rightPanel = ref(null)
watch(mapVisible, v => localStorage.setItem('sbv2-map', v ? 'true' : 'false'))
const editModal = ref(null)
function openEditModal (job) {
editModal.value = {
job, subject: job.subject || '', address: job.address || '',
note: job.note || '', duration: job.duration || 1,
priority: job.priority || 'low', tags: [...(job.tags || [])],
latitude: job.latitude || null, longitude: job.longitude || null,
}
}
function confirmEdit () {
if (!editModal.value) return
const { job, subject, address, note, duration, priority, tags, latitude, longitude } = editModal.value
Object.assign(job, { subject, address, note, duration: parseFloat(duration) || 1, priority, tags: [...tags] })
if (latitude) job.latitude = latitude
if (longitude) job.longitude = longitude
updateJob(job.name || job.id, {
subject, address, note: note || '', duration_h: job.duration, priority,
tags: tags.map(t => ({ tag: t })),
latitude: latitude || null, longitude: longitude || null,
}).catch(() => {})
editModal.value = null
invalidateRoutes()
}
const getJobDate = jobId => store.jobs.find(j => j.id === jobId)?.scheduledDate || null
const getJobTime = jobId => store.jobs.find(j => j.id === jobId)?.startTime || null
function setJobTime (jobId, time) {
const job = store.jobs.find(j => j.id === jobId)
if (!job) return
store.setJobSchedule(jobId, job.scheduledDate, time || null)
job.startHour = time ? timeToH(time) : null
}
const timeModal = ref(null)
const openTimeModal = (job, techId) => { timeModal.value = { job, techId, time: getJobTime(job.id) || '08:00', hasPin: !!getJobTime(job.id) } }
const confirmTime = () => { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, timeModal.value.time); timeModal.value = null }
const clearTime = () => { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, null); timeModal.value = null }
const pendingReqs = ref([])
const pendingLoading = ref(false)
async function loadPendingReqs () {
pendingLoading.value = true
try { pendingReqs.value = await fetchOpenRequests() } catch { pendingReqs.value = [] }
pendingLoading.value = false
}
const unscheduledJobs = computed(() => store.jobs.filter(j => !j.assignedTech))
const teamJobs = computed(() => store.jobs.filter(j => j.assistants?.length > 0))
const jobColor = job => _jobColorBase(job, TECH_COLORS, store)
const PX_PER_HR = ref(80)
const pxPerHr = computed(() => currentView.value === 'week' ? PX_PER_HR.value * 0.55 : currentView.value === 'month' ? 0 : PX_PER_HR.value)
const dayW = computed(() => currentView.value === 'month' ? 110 : (H_END.value - H_START.value) * pxPerHr.value)
const totalW = computed(() => dayW.value * dayColumns.value.length)
const viewportW = computed(() => dayW.value * periodDays.value)
// Week calendar: fixed col width = available board width / 7 visible days
const calColW = ref(0)
function measureCalColW () {
const el = boardScroll.value
if (!el) return
const available = el.clientWidth - 200 // minus resource column
calColW.value = Math.floor(available / periodDays.value)
}
const {
H_START, H_END, routeLegs, routeGeometry,
techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, ghostOccurrencesForDate,
periodLoadH, techPeriodCapacityH, techDayEndH,
} = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor)
// Wire up resource filter opts now that scheduler is available
_resFilterOpts.isAbsentOnDay = (tech) => {
const dayStr = localDateStr(periodStart.value)
return absenceSegmentsForDate(tech, dayStr).length > 0
}
_resFilterOpts.getLoadH = (tech) => periodLoadH(tech)
const { startAbsenceResize } = useAbsenceResize(pxPerHr, H_START)
const hourTicks = computed(() => {
if (currentView.value === 'month') return []
const ticks = []
dayColumns.value.forEach((day, di) => {
for (let h = H_START.value; h <= H_END.value; h++) {
const x = di * dayW.value + (h - H_START.value) * pxPerHr.value
ticks.push({ x, label: h===H_START.value||h%2===0 ? h+':00' : null, isMajor: true, isDay: h===H_START.value, day, h })
}
})
return ticks
})
const isCalView = computed(() => currentView.value === 'week')
const unassignDropActive = ref(false)
// Pre-compute expensive per-tech data (memoized — avoids recalculating on every render)
const segmentsMap = computed(() => {
const map = {}
for (const tech of filteredResources.value) map[tech.id] = techDayJobsWithTravel(tech)
return map
})
const loadMap = computed(() => {
const map = {}
for (const tech of filteredResources.value) map[tech.id] = periodLoadH(tech)
return map
})
const capMap = computed(() => {
const map = {}
for (const tech of filteredResources.value) map[tech.id] = techPeriodCapacityH(tech)
return map
})
// ── Resource utilization alerts ──────────────────────────────────────────────
const overloadedTechs = computed(() => {
return filteredResources.value.filter(tech => {
const load = loadMap.value[tech.id] || 0
const cap = capMap.value[tech.id] || 8
return load > cap
}).map(tech => ({
tech,
load: loadMap.value[tech.id] || 0,
cap: capMap.value[tech.id] || 8,
pct: Math.round(((loadMap.value[tech.id] || 0) / (capMap.value[tech.id] || 8)) * 100),
}))
})
const underutilizedTechs = computed(() => {
return filteredResources.value.filter(tech => {
if (tech.status === 'off' || tech.status === 'inactive') return false
const load = loadMap.value[tech.id] || 0
const cap = capMap.value[tech.id] || 8
return cap > 0 && load < cap * 0.3
})
})
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
const smartAssign = (job, newTechId, dateStr) => store.smartAssign(job.id, newTechId, dateStr)
// Confirmation state for unassign
const confirmUnassignDialog = ref(false)
const pendingUnassignJob = ref(null)
function _doUnassign (job) {
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] })
store.fullUnassign(job.id)
if (selectedJob.value?.job?.id === job.id) selectedJob.value = null
invalidateRoutes()
}
function fullUnassign (job) {
// Require confirmation for published or in-progress jobs
if (job.published || job.status === 'in_progress' || job.status === 'In Progress' || job.status === 'assigned') {
pendingUnassignJob.value = job
confirmUnassignDialog.value = true
return
}
_doUnassign(job)
}
function confirmUnassign () {
if (pendingUnassignJob.value) _doUnassign(pendingUnassignJob.value)
pendingUnassignJob.value = null
confirmUnassignDialog.value = false
}
function cancelUnassign () {
pendingUnassignJob.value = null
confirmUnassignDialog.value = false
}
const {
ctxMenu, techCtx, assistCtx, assistNoteModal,
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
assistCtxTogglePin, assistCtxRemove, assistCtxNote, confirmAssistNote,
ctxDetails, ctxMove, ctxUnschedule,
} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal })
const {
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
btColWidths, btColW, startColResize,
} = useBottomPanel(store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart })
const {
hoveredJobId, selectedJob, multiSelect,
selectJob: _selectJob, isJobMultiSelected, batchUnassign, batchMoveTo,
lasso, boardScroll, lassoStyle, startLasso, moveLasso, endLasso,
techHasLinkedJob, techIsHovered,
} = useSelection({ store, periodStart, smartAssign, invalidateRoutes, fullUnassign })
const selectJob = (job, techId, isAssist = false, assistTechId = null, event = null) => _selectJob(job, techId, isAssist, assistTechId, event, rightPanel)
const {
dragJob, dragSrc, dragIsAssist, dragBatchIds, dropGhost,
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
onTechDragStart,
onTimelineDrop, onCalDrop,
startBlockMove, startResize,
} = useDragDrop({
store, pxPerHr, dayW, periodStart, periodDays, H_START,
getJobDate, bottomSelected, multiSelect,
pushUndo, smartAssign, invalidateRoutes,
})
let computeDayRoute = () => {}, drawMapMarkers = () => {}, drawSelectedRoute = () => {}, getMap = () => null
const _map = useMap({
store, MAPBOX_TOKEN, TECH_COLORS,
currentView, periodStart, filteredResources, mapVisible,
routeLegs, routeGeometry,
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
dragJob, dragIsAssist, rightPanel, openCtxMenu,
})
const { mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, mapDragJob,
startGeoFix, cancelGeoFix, startMapResize, initMap, selectTechOnBoard, destroyMap, loadMapboxCss } = _map
computeDayRoute = _map.computeDayRoute
drawMapMarkers = _map.drawMapMarkers
drawSelectedRoute = _map.drawSelectedRoute
getMap = _map.getMap
function invalidateRoutes () {
routeLegs.value = {}; routeGeometry.value = {}
if (currentView.value === 'day') {
const ds = localDateStr(periodStart.value)
filteredResources.value.forEach(tech => computeDayRoute(tech, ds))
}
nextTick(() => { if (getMap()) { drawMapMarkers(); drawSelectedRoute() } })
}
watch(
[currentView, () => anchorDate.value.getTime(), filteredResources],
() => {
if (currentView.value !== 'day') return
routeLegs.value = {}; routeGeometry.value = {}
const ds = localDateStr(periodStart.value)
filteredResources.value.forEach(tech => computeDayRoute(tech, ds))
if (getMap()) { drawMapMarkers(); drawSelectedRoute() }
},
)
const moveModalOpen = ref(false)
const moveForm = ref(null)
function openMoveModal (job, srcTechId) {
moveForm.value = { job, srcTechId, newTechId: srcTechId, newDate: getJobDate(job.id) || todayStr }
moveModalOpen.value = true
}
async function confirmMove () {
if (!moveForm.value) return
const { job, srcTechId, newTechId, newDate } = moveForm.value
pushUndo({ type: 'unassignJob', jobId: job.id, techId: srcTechId, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants||[])] })
if (newTechId !== srcTechId) smartAssign(job, newTechId, newDate)
else store.setJobSchedule(job.id, newDate)
moveModalOpen.value = false; bookingOverlay.value = null; invalidateRoutes()
}
const bookingOverlay = ref(null)
const woModalOpen = ref(false)
const woModalCtx = ref({})
const publishModalOpen = ref(false)
const draftCount = computed(() => store.jobs.filter(j => !j.published && j.status !== 'completed' && j.status !== 'cancelled').length)
const periodEndStr = computed(() => {
const ps = periodStart.value
if (!ps || 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 gpsSettingsOpen = ref(false)
const gpsShowInactive = ref(false)
const gpsFilteredTechs = computed(() =>
gpsShowInactive.value ? store.technicians : store.technicians.filter(t => t.active !== false)
)
const {
editingTech, newTechName, newTechPhone, newTechDevice, addingTech,
absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing,
saveTechField, addTech,
openAbsenceModal, confirmAbsence, endAbsence,
deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule,
ABSENCE_REASONS,
} = useTechManagement(store, invalidateRoutes)
const newTechGroup = ref('')
const scheduleModalTech = ref(null)
const scheduleForm = ref({})
const extraShiftsForm = ref([]) // On-call / garde shifts
function openScheduleModal (tech) {
scheduleModalTech.value = tech
scheduleForm.value = {}
WEEK_DAYS.forEach(d => {
const day = tech.weeklySchedule?.[d]
scheduleForm.value[d] = day ? { on: true, start: day.start || '08:00', end: day.end || '16:00' } : { on: false, start: '08:00', end: '16:00' }
})
// Load existing extra shifts with UI-friendly _pattern/_interval
extraShiftsForm.value = (tech.extraShifts || []).map(s => _enrichShift({ ...s }))
}
const applySchedulePreset = preset => {
WEEK_DAYS.forEach(d => {
const day = preset.schedule[d]
scheduleForm.value[d] = day ? { on: true, start: day.start, end: day.end } : { on: false, start: '08:00', end: '16:00' }
})
}
function _parseShiftPattern (rrule) {
if (!rrule) return { pattern: 'weekend', interval: 1 }
if (rrule.includes('BYDAY=MO,TU,WE,TH,FR')) return { pattern: 'weeknight', interval: 1 }
if (rrule.includes('FREQ=DAILY')) return { pattern: 'daily', interval: 1 }
const m = rrule.match(/INTERVAL=(\d+)/)
return { pattern: 'weekend', interval: m ? parseInt(m[1]) : 1 }
}
function _enrichShift (s) {
const p = _parseShiftPattern(s.rrule)
s._pattern = p.pattern
s._interval = p.interval
return s
}
function updateShiftRrule (shift) {
if (shift._pattern === 'weekend') {
shift.rrule = `FREQ=WEEKLY;INTERVAL=${shift._interval || 1};BYDAY=SA,SU`
} else if (shift._pattern === 'weeknight') {
shift.rrule = 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR'
} else if (shift._pattern === 'daily') {
shift.rrule = 'FREQ=DAILY;INTERVAL=1'
}
}
function addExtraShift () {
extraShiftsForm.value.push(_enrichShift({
label: 'Garde',
startTime: '08:00',
endTime: '16:00',
rrule: 'FREQ=WEEKLY;INTERVAL=4;BYDAY=SA,SU',
from: todayStr.value || localDateStr(new Date()),
}))
}
function removeExtraShift (idx) {
extraShiftsForm.value.splice(idx, 1)
}
function confirmSchedule () {
const sched = {}
WEEK_DAYS.forEach(d => {
const f = scheduleForm.value[d]
sched[d] = f.on ? { start: f.start, end: f.end } : null
})
saveWeeklySchedule(scheduleModalTech.value, sched)
// Save extra shifts (strip transient UI fields)
const tech = scheduleModalTech.value
tech.extraShifts = extraShiftsForm.value
.filter(s => s.startTime && s.endTime && s.rrule)
.map(({ _pattern, _interval, ...rest }) => rest)
updateTech(tech.name || tech.id, { extra_shifts: JSON.stringify(tech.extraShifts) }).catch(() => {})
store.jobVersion++
scheduleModalTech.value = null
}
const resSelectorGroupFilter = ref('')
const resSelectorSearch = ref('')
const savedPresets = ref([])
const presetsLoaded = ref(false)
const presetNameInput = ref('')
const showPresetSave = ref(false)
// Migrate localStorage presets to ERPNext (one-time)
async function _migrateLocalPresets () {
const local = JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]')
if (!local.length) return
for (const p of local) {
if (savedPresets.value.some(sp => sp.name === p.name)) continue
try {
await apiCreatePreset({
preset_name: p.name,
preset_type: p.type || 'selection',
group_name: p.group || '',
tech_ids: JSON.stringify(p.ids || []),
is_shared: 1,
})
} catch {}
}
localStorage.removeItem('sbv2-resPresets')
}
async function loadPresets () {
try {
const raw = await fetchPresets()
savedPresets.value = raw.map(p => ({
name: p.preset_name || p.name,
docName: p.name,
type: p.preset_type || 'selection',
group: p.group_name || '',
ids: p.tech_ids ? JSON.parse(p.tech_ids) : [],
shared: !!p.is_shared,
createdBy: p.created_by_user || p.owner || '',
}))
presetsLoaded.value = true
// One-time migration
if (localStorage.getItem('sbv2-resPresets')) _migrateLocalPresets().then(loadPresets)
} catch (e) {
console.warn('[presets] load failed:', e.message)
// Fallback to localStorage
savedPresets.value = JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]')
}
}
async function savePreset () {
const name = presetNameInput.value.trim()
if (!name || !tempSelectedIds.value.length) return
const existing = savedPresets.value.find(p => p.name === name)
try {
if (existing?.docName) {
await import('src/api/presets').then(m => m.updatePreset(existing.docName, { tech_ids: JSON.stringify(tempSelectedIds.value) }))
} else {
await apiCreatePreset({
preset_name: name,
preset_type: 'selection',
tech_ids: JSON.stringify([...tempSelectedIds.value]),
is_shared: 1,
})
}
await loadPresets()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
presetNameInput.value = ''
showPresetSave.value = false
}
async function saveGroupAsPreset (groupName) {
if (!groupName) return
const techsInGroup = store.technicians.filter(t => t.group === groupName && t.status !== 'inactive')
if (!techsInGroup.length) return
try {
const existing = savedPresets.value.find(p => p.name === groupName && p.type === 'group')
if (existing?.docName) {
await import('src/api/presets').then(m => m.updatePreset(existing.docName, { tech_ids: JSON.stringify(techsInGroup.map(t => t.id)) }))
} else {
await apiCreatePreset({
preset_name: groupName,
preset_type: 'group',
group_name: groupName,
tech_ids: JSON.stringify(techsInGroup.map(t => t.id)),
is_shared: 1,
})
}
await loadPresets()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
function loadPreset (preset) {
if (preset.type === 'group' && preset.group) {
const techsInGroup = store.technicians.filter(t => t.group === preset.group && t.status !== 'inactive')
tempSelectedIds.value = techsInGroup.map(t => t.id)
} else {
tempSelectedIds.value = [...(preset.ids || [])]
}
}
async function deletePreset (idx) {
const preset = savedPresets.value[idx]
if (preset?.docName) {
try { await apiDeletePreset(preset.docName) } catch {}
}
savedPresets.value.splice(idx, 1)
}
function quickLoadPreset (preset) {
if (activePresetName.value === preset.name) {
selectedResIds.value = []
filterGroup.value = ''
return
}
if (preset.type === 'group' && preset.group) {
filterGroup.value = preset.group
selectedResIds.value = []
} else {
filterGroup.value = ''
selectedResIds.value = [...(preset.ids || [])]
}
}
const activePresetName = computed(() => {
// Check group presets via filterGroup
if (filterGroup.value) {
const gp = savedPresets.value.find(p => p.type === 'group' && p.group === filterGroup.value)
if (gp) return gp.name
}
// Check ID-based presets
if (!selectedResIds.value.length) return null
const ids = selectedResIds.value
return savedPresets.value.find(p => p.ids.length === ids.length && p.ids.every(id => ids.includes(id)))?.name || null
})
const resSelectorGroupsFiltered = computed(() => {
const grouper = (techs) => {
const groups = new Map()
for (const t of techs) {
const g = t.group || ''
if (!groups.has(g)) groups.set(g, [])
groups.get(g).push(t)
}
const sorted = [...groups.entries()].sort((a, b) => {
if (!a[0] && b[0]) return 1
if (a[0] && !b[0]) return -1
return a[0].localeCompare(b[0])
})
return sorted.map(([name, techs]) => ({ name, label: name || 'Sans groupe', techs }))
}
let techs = store.technicians.filter(t => t.status !== 'inactive')
if (resSelectorGroupFilter.value) techs = techs.filter(t => t.group === resSelectorGroupFilter.value)
if (resSelectorSearch.value) {
const q = resSelectorSearch.value.toLowerCase()
techs = techs.filter(t => t.fullName.toLowerCase().includes(q))
}
return {
available: grouper(techs.filter(x => !tempSelectedIds.value.includes(x.id))),
selected: grouper(store.technicians.filter(x => tempSelectedIds.value.includes(x.id))),
}
})
function resIcon (t) {
if (t.resourceType !== 'material') return ''
return RES_ICONS[t.resourceCategory] || RES_ICONS[t.fullName] || '🔧'
}
function openResSelectorFull () {
resSelectorGroupFilter.value = filterGroup.value
resSelectorSearch.value = ''
openResSelector()
}
const applyGroupFilter = () => { filterGroup.value = resSelectorGroupFilter.value; resSelectorOpen.value = false }
async function onTechStatusChange (tech, value) {
tech.status = value
tech.active = value !== 'inactive'
saveTechField(tech, 'status', value)
}
async function saveTechGroup (tech, value) {
const trimmed = (value || '').trim()
if (trimmed === tech.group) return
tech.group = trimmed
try { await import('src/api/dispatch').then(m => m.updateTech(tech.name || tech.id, { tech_group: trimmed })) }
catch {}
}
function openWoModal (prefillDate = null, prefillTech = null) {
woModalCtx.value = { scheduled_date: prefillDate || todayStr, assigned_tech: prefillTech || null }
woModalOpen.value = true
}
async function confirmWo (formData) {
return await store.createJob({
subject: formData.subject,
address: formData.address,
duration_h: formData.duration_h,
priority: formData.priority,
assigned_tech: formData.assigned_tech || null,
scheduled_date: formData.scheduled_date || null,
latitude: formData._latitude || null,
longitude: formData._longitude || null,
note: formData.description || '',
tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t),
depends_on: formData.depends_on || '',
})
}
const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs,
bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes,
})
function optimizeRoute () {
if (!techCtx.value) return
const tech = techCtx.value.tech; techCtx.value = null
_optimizeRoute(tech)
}
// ── Offer handlers ──────────────────────────────────────────────────────────
async function onOfferAccept (offer) {
// Show a quick tech picker for manual assignment from dispatcher side
const techs = matchingTechs(offer)
if (!techs.length) {
Notify.create({ type: 'warning', message: 'Aucun tech disponible pour cette offre', timeout: 3000 })
return
}
// For now, auto-assign to the first/best matching tech
// TODO: show a picker modal for dispatcher to choose
try {
await handleAccept(offer.id, techs[0].id)
Notify.create({ type: 'positive', message: `Offre acceptée → ${techs[0].fullName}`, timeout: 3000 })
invalidateRoutes()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
}
}
function offerUnassignedJob (job) {
createOfferPrefill.value = job
createOfferModal.value = true
}
async function onCreateOffer (formData, sms) {
try {
await broadcastOffer(formData, sms)
Notify.create({ type: 'positive', message: 'Offre diffusée' + (sms ? ' (SMS envoyés)' : ''), timeout: 3000 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
}
}
// ── Ghost / Recurring handlers ────────────────────────────────────────────
function onGhostClick (templateJob, dateStr, techId) {
const tech = store.technicians.find(t => t.id === techId)
bookingOverlay.value = { job: templateJob, tech, ghostDate: dateStr }
}
async function materializeGhost (templateJob, dateStr, techId) {
const newJob = await store.createJob({
subject: templateJob.subject,
address: templateJob.address,
longitude: templateJob.coords?.[0],
latitude: templateJob.coords?.[1],
duration_h: templateJob.duration,
priority: templateJob.priority,
assigned_tech: techId,
scheduled_date: dateStr,
start_time: templateJob.startTime || '',
customer: templateJob.customer || '',
service_location: templateJob.serviceLocation || '',
template_id: templateJob.id,
})
store.jobVersion++
invalidateRoutes()
Notify.create({ type: 'positive', message: `Job matérialisé pour le ${dateStr}`, timeout: 2000 })
}
function toggleRecurring (job) {
job.isRecurring = !job.isRecurring
if (job.isRecurring && !job.recurrenceRule) job.recurrenceRule = 'FREQ=WEEKLY;BYDAY=MO'
store.jobVersion++
updateJob(job.name || job.id, {
is_recurring: job.isRecurring ? 1 : 0,
recurrence_rule: job.recurrenceRule || '',
}).catch(() => {})
}
function updateRecurrence (job, rrule) {
job.recurrenceRule = rrule
store.jobVersion++
updateJob(job.name || job.id, { recurrence_rule: rrule }).catch(() => {})
}
function updateRecurrenceEnd (job, endDate) {
job.recurrenceEnd = endDate || null
store.jobVersion++
updateJob(job.name || job.id, { recurrence_end: endDate || '' }).catch(() => {})
}
function addPausePeriod (job) {
const pauses = [...(job.pausePeriods || []), { from: localDateStr(new Date()), until: '' }]
job.pausePeriods = pauses
store.jobVersion++
updateJob(job.name || job.id, { pause_periods: JSON.stringify(pauses) }).catch(() => {})
}
function updatePausePeriod (job, idx, field, val) {
const pauses = [...(job.pausePeriods || [])]
pauses[idx] = { ...pauses[idx], [field]: val }
job.pausePeriods = pauses
store.jobVersion++
updateJob(job.name || job.id, { pause_periods: JSON.stringify(pauses) }).catch(() => {})
}
function removePausePeriod (job, idx) {
const pauses = [...(job.pausePeriods || [])]
pauses.splice(idx, 1)
job.pausePeriods = pauses
store.jobVersion++
updateJob(job.name || job.id, { pause_periods: JSON.stringify(pauses) }).catch(() => {})
}
async function copyIcalUrl (tech) {
const hubBase = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
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 = []
}
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`)
dispatchSse.addEventListener('tech-absence', (e) => {
try {
const data = JSON.parse(e.data)
const tech = store.technicians.find(t => t.name === data.techName || t.id === data.techName)
if (!tech) return
if (data.action === 'set') {
tech.status = 'off'
tech.absenceFrom = data.from || null
tech.absenceUntil = data.until || null
tech.absenceStartTime = data.startTime || null
tech.absenceEndTime = data.endTime || null
tech.absenceReason = data.reason || 'personal'
} else if (data.action === 'clear') {
tech.status = 'available'
tech.absenceFrom = null
tech.absenceUntil = null
tech.absenceStartTime = null
tech.absenceEndTime = null
tech.absenceReason = ''
}
} catch {}
})
}
// 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 })
if (!document.getElementById('mapbox-css')) {
const l = document.createElement('link'); l.id='mapbox-css'; l.rel='stylesheet'
l.href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l)
}
store.startGpsTracking()
connectDispatchSSE()
// Measure column width for week calendar, scroll to center
measureCalColW()
nextTick(() => scrollToCenter())
if (boardScroll.value) boardScroll.value.addEventListener('scroll', onBoardScroll, { passive: true })
window.addEventListener('resize', measureCalColW)
})
// Re-measure column widths when switching views (day→week changes periodDays 1→7)
watch([currentView, periodDays], () => nextTick(() => { measureCalColW(); scrollToCenter() }))
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('click', closeCtxMenu)
if (boardScroll.value) boardScroll.value.removeEventListener('scroll', onBoardScroll)
window.removeEventListener('resize', measureCalColW)
destroyMap(); store.stopGpsTracking(); if (dispatchSse) dispatchSse.close()
})
</script>
<template>
<div class="sb-root" @click="onRootClick">
<header class="sb-header">
<div class="sb-header-left">
<div class="sb-search-bar" @click="openResSelectorFull">
<span class="sb-search-icon">🔍</span>
<span v-if="filterGroup" class="sb-search-chip" @click.stop="filterGroup=''">{{ filterGroup }} ✕</span>
<span v-if="activePresetName" class="sb-search-chip" @click.stop="selectedResIds=[]">{{ activePresetName }} ✕</span>
<span v-else-if="selectedResIds.length" class="sb-search-chip sb-search-chip-count" @click.stop="selectedResIds=[]">{{ selectedResIds.length }} ressource{{ selectedResIds.length>1?'s':'' }} ✕</span>
<span v-if="!filterGroup && !selectedResIds.length" class="sb-search-placeholder">Ressources…</span>
</div>
<!-- Quick saved group access -->
<div v-if="savedPresets.length" class="sb-quick-presets">
<button v-for="p in savedPresets" :key="p.name"
class="sb-quick-preset" :class="{ active: activePresetName === p.name }"
@click.stop="quickLoadPreset(p)"
:title="(p.type === 'group' ? 'Groupe: ' : 'Sélection: ') + p.name">
<span v-if="p.type === 'group'" class="sb-qp-icon">👥</span>{{ p.name }}
</button>
</div>
<div v-if="materialCount > 0" class="sb-res-type-toggle">
<button :class="{ active: !filterResourceType }" @click="filterResourceType=''">Tous <span class="sbf-count">{{ humanCount + materialCount }}</span></button>
<button :class="{ active: filterResourceType==='human' }" @click="filterResourceType='human'">👤 <span class="sbf-count">{{ humanCount }}</span></button>
<button :class="{ active: filterResourceType==='material' }" @click="filterResourceType='material'">🔧 <span class="sbf-count">{{ materialCount }}</span></button>
</div>
<div class="sb-tabs">
<button v-for="tab in boardTabs" :key="tab" class="sb-tab" :class="{ active: activeTab===tab }" @click="activeTab=tab">{{ tab }}</button>
<button class="sb-tab sb-tab-add" title="Nouvelle vue">+</button>
</div>
<button class="sb-icon-btn" :class="{ active: filterPanelOpen }" @click="filterPanelOpen=!filterPanelOpen" title="Filtres & Ressources">
<span v-html="ICON.wrench"></span>
<span v-if="filterStatus||filterGroup||selectedResIds.length||filterTags.length||hideAbsent" class="sbs-badge" style="position:relative;top:-4px;right:2px"></span>
</button>
<button v-if="teamJobs.length" class="sb-icon-btn" :class="{ active: projectsPanelOpen }" @click="projectsPanelOpen=!projectsPanelOpen" title="Projets">
👥 <span class="sbs-count" style="position:relative;top:-2px;right:auto">{{ teamJobs.length }}</span>
</button>
</div>
<div class="sb-header-center">
<button class="sb-hbtn sb-today-btn" @click="goToToday">Aujourd'hui</button>
<button class="sb-hbtn" @click="prevPeriod"></button>
<span class="sb-period-label">{{ periodLabel }}</span>
<button class="sb-hbtn" @click="nextPeriod"></button>
<div class="sb-view-sw">
<button v-for="v in [['day','Jour'],['week','Semaine'],['month','Mois']]" :key="v[0]" :class="{ active: currentView===v[0] }" @click="currentView=v[0]">{{ v[1] }}</button>
</div>
<button class="sb-icon-btn sb-planning-toggle" :class="{ active: planningMode }" @click="planningMode=!planningMode" title="Mode planification — afficher les disponibilités">
🗓 <span v-if="currentView!=='month'" style="font-size:0.72rem">Planning</span>
</button>
</div>
<div class="sb-header-right">
<!-- Overload alert -->
<span v-if="overloadedTechs.length" class="sb-overload-alert" :title="overloadedTechs.map(o => o.tech.fullName + ' ' + o.pct + '%').join(', ')">
⚠️ {{ overloadedTechs.length }} surchargé{{ overloadedTechs.length > 1 ? 's' : '' }}
</span>
<button class="sb-icon-btn" :class="{ active: bottomPanelOpen }" @click="bottomPanelOpen=!bottomPanelOpen" title="Jobs non assignées">
📋 <span v-if="unscheduledJobs.length" class="sbs-count" style="position:relative;top:-2px;right:auto">{{ unscheduledJobs.length }}</span>
</button>
<!-- Offer pool -->
<button class="sb-icon-btn" :class="{ active: showOfferPool }" @click="showOfferPool=!showOfferPool; if(showOfferPool) loadOffers()" title="Offres de travail">
📡 <span v-if="activeOfferCount" class="sbs-count" style="position:relative;top:-2px;right:auto;background:#4ade80;color:#000">{{ activeOfferCount }}</span>
</button>
<button v-if="currentView==='day'" class="sb-icon-btn" :class="{ active: mapVisible }" @click="mapVisible=!mapVisible" title="Carte">🗺 Carte</button>
<button class="sb-icon-btn" @click="refreshData()" title="Actualiser">↻</button>
<button class="sb-icon-btn" @click="gpsSettingsOpen=true" title="GPS Tracking">📡</button>
<button class="sb-wo-btn" style="background:#7c3aed" @click="publishModalOpen=true" title="Publier & envoyer l'horaire">
Publier <span v-if="draftCount" class="sbs-count" style="position:relative;top:-2px;right:auto;background:#ef4444">{{ draftCount }}</span>
</button>
<button class="sb-wo-btn" @click="openWoModal()" title="Nouveau work order">+ WO</button>
<a class="sb-erp-link" :href="erpUrl + '/desk'" target="_blank" title="Ouvrir ERPNext">ERP</a>
<div class="sb-erp-dot" :class="{ ok: store.erpStatus==='ok' }" :title="{ ok:'ERPNext ✓', error:'Hors ligne', loading:'Connexion…' }[store.erpStatus]||'ERPNext'"></div>
</div>
</header>
<transition name="sb-slide-down">
<div v-if="filterPanelOpen" class="sb-toolbar-panel" @click.stop>
<div class="sb-toolbar-panel-inner">
<div class="sbf-section" style="border-bottom:none;display:flex;gap:1rem;flex-wrap:wrap;padding:0.5rem 0.75rem">
<div style="min-width:180px">
<div class="sbf-title">Ressources</div>
<input class="sbs-search-full" v-model="searchQuery" placeholder="🔍 Rechercher une ressource…" />
<button class="sbf-primary-btn" style="margin-top:0.3rem" @click="openResSelectorFull">Sélectionner les ressources</button>
<label class="sbf-lbl" style="margin-top:0.3rem">Tri</label>
<select class="sbf-select" v-model="techSort">
<option value="default">Par défaut</option><option value="alpha">Alphabétique (nom)</option><option value="load">Moins chargés d'abord</option><option value="manual">Manuel (drag)</option>
</select>
<div v-if="selectedResIds.length" class="sbf-chip">{{ selectedResIds.length }} sélectionnée{{ selectedResIds.length>1?'s':'' }}<button @click="selectedResIds=[]">✕</button></div>
</div>
<div style="min-width:180px">
<div class="sbf-title">Filtres</div>
<label class="sbf-lbl">Groupe</label>
<select class="sbf-select" v-model="filterGroup">
<option value="">Tous les groupes</option>
<option v-for="g in availableGroups" :key="g" :value="g">{{ g }}</option>
</select>
<label class="sbf-lbl">Statut</label>
<select class="sbf-select" v-model="filterStatus">
<option value="">Tous (actifs)</option><option value="available">Disponible</option><option value="en-route">En route</option><option value="busy">En cours</option><option value="off">Hors shift</option><option value="inactive">Inactifs</option>
</select>
<label class="sbf-lbl" style="margin-top:0.4rem">
<input type="checkbox" v-model="hideAbsent" style="margin-right:4px;vertical-align:middle" />
Disponibles seulement
</label>
<label class="sbf-lbl">Tags</label>
<TagEditor :model-value="filterTags" @update:model-value="v => { filterTags = v; localStorage.setItem('sbv2-filterTags', JSON.stringify(v)) }"
:all-tags="store.allTags" :get-color="getTagColor" :can-create="false" :can-edit="false" placeholder="Filtrer par tag…" />
</div>
<div style="min-width:120px">
<div class="sbf-title">Légende</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
<span style="display:inline-block;width:18px;height:12px;border-radius:3px;background:#6366f1"></span>
<span style="font-size:0.7rem;color:#c4c8e4">Publié</span>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="display:inline-block;width:18px;height:12px;border-radius:3px;border:1px dashed rgba(255,255,255,0.3);background:repeating-linear-gradient(-45deg,transparent,transparent 3px,rgba(255,255,255,0.08) 3px,rgba(255,255,255,0.08) 6px),#6366f1"></span>
<span style="font-size:0.7rem;color:#c4c8e4">Brouillon</span>
</div>
</div>
<div style="display:flex;align-items:flex-end;margin-left:auto">
<button v-if="filterStatus||filterGroup||selectedResIds.length||searchQuery||filterTags.length||hideAbsent" class="sbf-clear-btn" style="width:auto;padding:0.22rem 0.6rem" @click="clearFilters">✕ Réinitialiser</button>
<button class="sb-icon-btn" style="margin-left:0.3rem" @click="filterPanelOpen=false" title="Fermer">✕</button>
</div>
</div>
</div>
</div>
</transition>
<transition name="sb-slide-down">
<div v-if="projectsPanelOpen" class="sb-toolbar-panel" @click.stop>
<div class="sb-toolbar-panel-inner" style="max-height:300px;overflow-y:auto">
<div style="display:flex;align-items:center;padding:0.4rem 0.75rem;gap:0.5rem">
<div class="sbf-title" style="margin:0">Projets <span class="sbf-count">{{ teamJobs.length }}</span></div>
<div style="flex:1"></div>
<button class="sb-icon-btn" @click="projectsPanelOpen=false" title="Fermer">✕</button>
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.4rem;padding:0 0.75rem 0.5rem">
<div v-for="job in teamJobs" :key="'tj-'+job.id" class="sbf-card" style="width:220px"
@click="rightPanel={ mode:'details', data:{ job, tech: store.technicians.find(t=>t.id===job.assignedTech) } }">
<div class="sbf-card-stripe" :style="'background:'+jobColor(job)"></div>
<div class="sbf-card-body">
<div class="sbf-card-title">{{ job.subject }}</div>
<div class="sbf-card-meta">⏱ {{ fmtDur(job.duration) }} · {{ job.scheduledDate || '—' }}</div>
<div class="sbf-team-badges">
<span class="sb-assist-badge sb-assist-badge-lead" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===job.assignedTech)?.colorIdx||0]">
{{ (store.technicians.find(t=>t.id===job.assignedTech)?.fullName||'?').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</span>
<span v-for="a in job.assistants" :key="a.techId" class="sb-assist-badge" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===a.techId)?.colorIdx||0]" :title="a.techName">
{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
<div class="sb-body">
<div v-if="bookingOverlay" class="sb-overlay-backdrop" @click.self="bookingOverlay=null"></div>
<transition name="sb-slide-left">
<div v-if="bookingOverlay" class="sb-left-overlay" @click.stop="()=>{}">
<div class="sb-rp-hdr"><span class="sb-rp-title">Réservation</span><button class="sb-rp-close" @click="bookingOverlay=null">✕</button></div>
<div class="sb-rp-body">
<div class="sb-rp-color-bar" :style="'background:'+jobColor(bookingOverlay.job||{})"></div>
<div v-for="f in [['Titre',bookingOverlay.job?.subject],['Adresse',bookingOverlay.job?.address],['Durée',fmtDur(bookingOverlay.job?.duration)],['Technicien',bookingOverlay.tech?.fullName||'—'],['Statut',bookingOverlay.job?.status]]" :key="f[0]" class="sb-rp-field">
<span class="sb-rp-lbl">{{ f[0] }}</span>{{ f[1] }}
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Priorité</span><span :class="prioClass(bookingOverlay.job?.priority)">{{ prioLabel(bookingOverlay.job?.priority) }}</span></div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Date planifiée</span>{{ bookingOverlay.job?.scheduledDate || '—' }}<span v-if="bookingOverlay.job?.endDate"> → {{ bookingOverlay.job.endDate }}</span></div>
<div v-if="bookingOverlay.job?.assignedTech" class="sb-rp-field">
<span class="sb-rp-lbl">Date de fin</span>
<input type="date" class="sb-form-input" :value="bookingOverlay.job?.endDate || ''" @change="setEndDate(bookingOverlay.job, $event.target.value)" style="margin-top:2px" />
</div>
<div v-if="bookingOverlay.job?.endDate" class="sb-rp-field" style="display:flex;align-items:center;gap:8px">
<label style="display:flex;align-items:center;gap:4px;font-size:0.72rem;cursor:pointer">
<input type="checkbox" :checked="bookingOverlay.job?.continuous" @change="toggleContinuous(bookingOverlay.job, $event.target.checked)" />
Urgence / continu (inclure fins de semaine)
</label>
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Tags</span>
<TagEditor v-if="bookingOverlay.job" :model-value="bookingOverlay.job.tagsWithLevel || bookingOverlay.job.tags || []"
@update:model-value="v => { bookingOverlay.job.tagsWithLevel = v; bookingOverlay.job.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistJobTags(bookingOverlay.job) }"
:all-tags="store.allTags" :get-color="getTagColor" :show-required="true" :show-level="true"
level-label="Niveau min." level-hint="1 = basique · 5 = expert requis"
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
</div>
<div v-if="bookingOverlay.job?.assistants?.length" class="sb-rp-field">
<span class="sb-rp-lbl">Assistants</span>
<div v-for="a in bookingOverlay.job.assistants" :key="a.techId" style="display:flex;align-items:center;gap:6px;margin-top:3px">
<span class="sb-assist-badge" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===a.techId)?.colorIdx||0]">{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</span>
<span style="font-size:0.72rem">{{ a.techName }} · {{ fmtDur(a.duration) }}{{ a.note ? ' · '+a.note : '' }}</span>
<button style="margin-left:auto;background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem" @click="store.removeAssistant(bookingOverlay.job.id, a.techId); invalidateRoutes()">✕</button>
</div>
</div>
<!-- Recurrence -->
<div class="sb-rp-field" style="border-top:1px solid rgba(255,255,255,0.08);padding-top:8px;margin-top:4px">
<label style="display:flex;align-items:center;gap:4px;font-size:0.72rem;cursor:pointer">
<input type="checkbox" :checked="bookingOverlay.job?.isRecurring" @change="toggleRecurring(bookingOverlay.job)" />
🔄 Récurrence
</label>
</div>
<template v-if="bookingOverlay.job?.isRecurring">
<div class="sb-rp-field">
<RecurrenceSelector
:model-value="bookingOverlay.job.recurrenceRule || ''"
:ref-date="bookingOverlay.job.scheduledDate || todayStr"
:show-none="false"
@update:model-value="rrule => updateRecurrence(bookingOverlay.job, rrule)"
/>
</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Fin de récurrence</span>
<input type="date" class="sb-form-input" :value="bookingOverlay.job?.recurrenceEnd || ''" @change="updateRecurrenceEnd(bookingOverlay.job, $event.target.value)" />
</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Pauses (ex: hiver)</span>
<div v-for="(p, idx) in (bookingOverlay.job?.pausePeriods||[])" :key="idx" style="display:flex;gap:4px;align-items:center;margin-top:3px">
<input type="date" class="sb-form-input" style="width:120px" :value="p.from" @change="updatePausePeriod(bookingOverlay.job, idx, 'from', $event.target.value)" />
<span style="font-size:0.7rem;color:#9ca3af">→</span>
<input type="date" class="sb-form-input" style="width:120px" :value="p.until" @change="updatePausePeriod(bookingOverlay.job, idx, 'until', $event.target.value)" />
<button style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem" @click="removePausePeriod(bookingOverlay.job, idx)">✕</button>
</div>
<button class="sb-rp-btn" style="margin-top:4px;font-size:0.68rem;padding:2px 8px" @click="addPausePeriod(bookingOverlay.job)">+ Ajouter une pause</button>
</div>
</template>
</div>
<div class="sb-rp-actions">
<button v-if="bookingOverlay.ghostDate" class="sb-rp-primary" @click="materializeGhost(bookingOverlay.job, bookingOverlay.ghostDate, bookingOverlay.tech?.id); bookingOverlay=null">✅ Matérialiser pour le {{ bookingOverlay.ghostDate }}</button>
<button v-else class="sb-rp-primary" @click="openMoveModal(bookingOverlay.job, bookingOverlay.tech?.id)">↔ Déplacer / Réassigner</button>
<button class="sb-rp-btn" @click="startGeoFix(bookingOverlay.job)">📍 Géofixer sur la carte</button>
<button class="sb-rp-btn" @click="ctxMenu={job:bookingOverlay.job,techId:bookingOverlay.tech?.id};ctxUnschedule();bookingOverlay=null">✕ Désaffecter</button>
</div>
</div>
</transition>
<div class="sb-center-col">
<div class="sb-board" ref="boardScroll" @mousedown="startLasso($event)" @mousemove="moveLasso($event)" @mouseup="if(lasso) _lassoJustEnded = true; endLasso($event)">
<div v-if="lasso" class="sb-lasso" :style="lassoStyle"></div>
<WeekCalendar v-if="isCalView"
:filtered-resources="filteredResources" :day-columns="dayColumns"
:selected-tech-id="selectedTechId" :drop-ghost="dropGhost" :today-str="todayStr"
:col-w="calColW"
@go-to-day="goToDay" @select-tech="selectTechOnBoard" @ctx-tech="openTechCtx"
@tech-reorder-start="onTechReorderStart" @tech-reorder-drop="onTechReorderDrop"
@cal-drop="onCalDrop" @job-dragstart="onJobDragStart"
@job-click="selectJob" @job-dblclick="openEditModal" @job-ctx="openCtxMenu"
@clear-filters="clearFilters"
@ghost-click="onGhostClick" @ghost-materialize="materializeGhost"
@open-absence="openAbsenceModal" @end-absence="endAbsence"
@open-schedule="openScheduleModal" />
<MonthCalendar v-else-if="currentView==='month'"
:anchor-date="anchorDate" :filtered-resources="filteredResources" :today-str="todayStr"
:selected-tech-id="selectedTechId"
@go-to-day="goToDay" @select-tech="selectTechOnBoard" @open-schedule="openScheduleModal" />
<div v-else class="sb-grid" :style="'min-width:'+(220+totalW)+'px'">
<div class="sb-grid-hdr">
<div class="sb-res-hdr">Ressources <span class="sbf-count">{{ filteredResources.length }}</span></div>
<div class="sb-time-hdr-wrap" :style="'width:'+totalW+'px;position:relative;height:100%'">
<div v-for="tick in hourTicks" :key="'dht-'+tick.x" class="sb-htick" :class="{ 'sb-day-boundary': tick.isDay }" :style="'left:'+tick.x+'px'">
<span v-if="tick.isDay && dayColumns.length > 1" class="sb-day-lbl" :class="{ 'sb-day-today': localDateStr(tick.day) === todayStr }">
{{ fmtDate(tick.day) }}
</span>
<span v-else-if="tick.label" class="sb-htick-lbl">{{ tick.label }}</span>
</div>
</div>
</div>
<div v-if="store.loading" class="sb-loading-row">Chargement…</div>
<div v-else-if="!filteredResources.length" class="sb-empty-row">
Aucune ressource. <button class="sbf-primary-btn" style="display:inline-block;margin-left:0.75rem" @click="clearFilters">Réinitialiser</button>
</div>
<TimelineRow v-for="tech in filteredResources" :key="tech.id"
:tech="tech" :segments="segmentsMap[tech.id] || []"
:hour-ticks="hourTicks" :total-w="totalW" :px-per-hr="pxPerHr"
:h-start="H_START" :h-end="H_END" :row-h="ROW_H"
:is-selected="selectedTechId===tech.id"
:is-elevated="techHasLinkedJob(tech)||techIsHovered(tech)"
:drop-ghost-x="dropGhost?.techId===tech.id ? dropGhost.x : null"
@select-tech="selectTechOnBoard" @ctx-tech="openTechCtx" @open-tech-tags="t => techTagModal = t"
@drag-tech-start="(e, tech) => { onTechReorderStart(e, tech); onTechDragStart(e, tech) }" @reorder-drop="onTechReorderDrop"
@timeline-dragover="onTimelineDragOver" @timeline-dragleave="onTimelineDragLeave"
@timeline-drop="onTimelineDrop"
@job-dragstart="onJobDragStart" @job-click="selectJob"
@job-dblclick="openEditModal" @job-ctx="openCtxMenu"
@assist-ctx="openAssistCtx"
@hover-job="id => hoveredJobId=id" @unhover-job="hoveredJobId=null"
@block-move="startBlockMove" @block-resize="startResize"
@absence-resize="startAbsenceResize"
@ghost-click="onGhostClick" @ghost-materialize="materializeGhost"
@open-absence="openAbsenceModal" @end-absence="endAbsence"
@open-schedule="openScheduleModal" />
</div>
</div>
<BottomPanel :open="bottomPanelOpen" :height="bottomPanelH"
:groups="unassignedGrouped" :unscheduled-count="unscheduledJobs.length"
:selected="bottomSelected" :drop-active="unassignDropActive"
@update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize"
@toggle-select="toggleBottomSelect" @select-all="selectAllBottom" @clear-select="clearBottomSelect"
@lasso-select="ids => { const s = new Set(bottomSelected); ids.forEach(id => s.add(id)); bottomSelected = s }"
@deselect-all="() => { clearBottomSelect(); selectedJob = null; multiSelect = []; rightPanel = null }"
@batch-assign="batchAssignBottom" @auto-distribute="autoDistribute"
@open-criteria="dispatchCriteriaModal=true"
@row-click="(job, ev) => { if(ev?.shiftKey || ev?.ctrlKey || ev?.metaKey) { toggleBottomSelect(job.id, ev) } else { rightPanel={ mode:'details', data:{ job, tech:null } } } }"
@row-dblclick="openEditModal"
@row-dragstart="(e, job) => onJobDragStart(e, job, null)"
@drop-unassign="(e, type) => { if(type==='over') unassignDropActive=!!dragJob; else if(type==='leave') unassignDropActive=false; else onDropUnassign(e) }" />
</div>
<div v-if="mapVisible" class="sb-map-backdrop" @click="mapVisible=false"></div>
<div v-if="mapVisible" class="sb-map-panel" @click.stop="()=>{}" :style="`width:${mapPanelW}px;min-width:${mapPanelW}px`">
<div class="sb-map-resize-handle" @mousedown.prevent="startMapResize"></div>
<div class="sb-map-bar" :class="{ 'sb-map-bar-geofix': geoFixJob }">
<span class="sb-map-title">Carte</span>
<template v-if="geoFixJob">
<span class="sb-geofix-hint">📍 Cliquer sur la carte pour placer <strong>{{ geoFixJob.subject }}</strong></span>
<button class="sb-geofix-cancel" @click="cancelGeoFix">✕ Annuler</button>
</template>
<template v-else>
<span v-if="selectedTechId" class="sb-map-tech" :style="'color:'+TECH_COLORS[store.technicians.find(t=>t.id===selectedTechId)?.colorIdx||0]">
● {{ store.technicians.find(t=>t.id===selectedTechId)?.fullName }}
<span class="sb-map-route-hint">· Glisser une job sur le trajet</span>
</span>
<span v-else class="sb-map-hint">Cliquer un technicien pour voir son trajet</span>
<button class="sb-map-close" @click="mapVisible=false">✕</button>
</template>
</div>
<div class="sb-map-legend">
<div v-for="(col, lbl) in SVC_COLORS" :key="lbl" class="sb-legend-item">
<span class="sb-legend-dot" :style="'background:'+col"></span>{{ lbl }}
</div>
</div>
<div ref="mapContainer" class="sb-map"></div>
</div>
<RightPanel :panel="rightPanel"
@close="rightPanel=null; selectedJob=null"
@edit="openEditModal" @move="openMoveModal" @geofix="startGeoFix"
@unassign="job => { fullUnassign(job); rightPanel=null }"
@set-end-date="setEndDate"
@remove-assistant="(jobId, techId) => { store.removeAssistant(jobId, techId); invalidateRoutes() }"
@assign-pending="() => rightPanel=null"
@update-tags="(job, v) => { job.tags = v; persistJobTags(job) }" />
<!-- Offer pool slide-in panel -->
<transition name="sb-slide-right">
<div v-if="showOfferPool" class="sb-offer-pool-col">
<OfferPoolPanel
:offers="offers" :loading="loadingOffers"
@refresh="loadOffers"
@close="showOfferPool=false"
@offer-job="createOfferPrefill=null; createOfferModal=true"
@accept="o => onOfferAccept(o)"
@cancel="o => handleCancel(o.id)"
/>
</div>
</transition>
</div>
<!-- Context menus -->
<SbContextMenu :pos="ctxMenu">
<button class="sb-ctx-item" @click="ctxDetails()">📄 Voir détails</button>
<button class="sb-ctx-item" @click="ctxMove()">↔ Déplacer / Réassigner</button>
<button class="sb-ctx-item" @click="openTimeModal(ctxMenu.job, ctxMenu.techId); closeCtxMenu()">🕐 Fixer l'heure</button>
<button class="sb-ctx-item" @click="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button>
<button class="sb-ctx-item" @click="offerUnassignedJob(ctxMenu.job); closeCtxMenu()">📡 Offrir aux ressources</button>
<div class="sb-ctx-sep"></div>
<button class="sb-ctx-item sb-ctx-warn" @click="ctxUnschedule()">✕ Désaffecter</button>
</SbContextMenu>
<SbContextMenu :pos="techCtx">
<button class="sb-ctx-item" @click="selectTechOnBoard(techCtx.tech); techCtx=null">🗺 Voir sur la carte</button>
<button class="sb-ctx-item" @click="optimizeRoute()">🔀 Optimiser la route</button>
<button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button>
<button class="sb-ctx-item" @click="copyIcalUrl(techCtx.tech); techCtx=null">📅 Copier le lien iCal</button>
<div class="sb-ctx-sep"></div>
<button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null">↗ Ouvrir dans ERPNext</button>
</SbContextMenu>
<SbContextMenu :pos="assistCtx">
<button class="sb-ctx-item" @click="assistCtxTogglePin()">
{{ assistCtx?.job?.assistants?.find(a=>a.techId===assistCtx?.techId)?.pinned ? '↕ Rendre flottant' : '📌 Prioriser dans le timeline' }}
</button>
<button class="sb-ctx-item" @click="assistCtxNote()">📝 Modifier la note</button>
<button class="sb-ctx-item" @click="bookingOverlay={job:assistCtx.job, tech:store.technicians.find(t=>t.id===assistCtx.job.assignedTech)}; assistCtx=null">📄 Voir le job parent</button>
<div class="sb-ctx-sep"></div>
<button class="sb-ctx-item sb-ctx-warn" @click="assistCtxRemove()">✕ Retirer l'assistant</button>
</SbContextMenu>
<transition name="sb-slide-up">
<div v-if="multiSelect.length" class="sb-multi-bar">
<span class="sb-multi-count">{{ multiSelect.length }} sélectionné{{ multiSelect.length>1?'s':'' }}</span>
<button class="sb-multi-btn" @click="batchUnassign(pushUndo)">✕ Désaffecter</button>
<span class="sb-multi-sep">|</span>
<span class="sb-multi-lbl">Déplacer vers :</span>
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech" :style="'border-color:'+TECH_COLORS[t.colorIdx]" @click="batchMoveTo(t.id, localDateStr(periodStart), pushUndo)">
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase() }}
</button>
<button class="sb-multi-btn sb-multi-clear" @click="multiSelect=[]; selectedJob=null">Annuler</button>
</div>
</transition>
<!-- Tech tags modal -->
<SbModal :model-value="!!techTagModal" @update:model-value="v => { if(!v) techTagModal=null }" modal-class="sb-modal-tags" body-style="overflow:visible;min-height:320px">
<template #header><span>🏷 Tags — {{ techTagModal?.fullName }}</span></template>
<TagEditor v-if="techTagModal" :model-value="techTagModal.tagsWithLevel || []" @update:model-value="v => { techTagModal.tagsWithLevel = v; techTagModal.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistTechTags(techTagModal) }"
:all-tags="store.allTags" :get-color="getTagColor" :show-level="true"
level-label="Compétence" level-hint="1 = base · 5 = expert"
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
<template #footer><button class="sb-rp-primary" @click="techTagModal=null">Fermer</button></template>
</SbModal>
<!-- Assistant note modal -->
<SbModal :model-value="!!assistNoteModal" @update:model-value="v => { if(!v) assistNoteModal=null }">
<template #header><span>📝 Note assistant</span></template>
<template v-if="assistNoteModal">
<label class="sb-form-lbl">Titre / note pour cette tâche</label>
<input type="text" class="sb-form-input" v-model="assistNoteModal.note" placeholder="Ex: Livraison outil, Support câblage..." @keyup.enter="confirmAssistNote" />
<p style="font-size:0.6rem;color:#7b80a0;margin-top:0.3rem">Job parent : {{ assistNoteModal.job?.subject }}</p>
</template>
<template #footer><button class="sb-rp-primary" @click="confirmAssistNote">Enregistrer</button><button class="sb-rp-btn" @click="assistNoteModal=null">Annuler</button></template>
</SbModal>
<!-- Time pin modal -->
<SbModal :model-value="!!timeModal" @update:model-value="v => { if(!v) timeModal=null }">
<template #header><span>🕐 Heure de début fixe</span></template>
<template v-if="timeModal">
<div class="sb-form-row"><label class="sb-form-lbl">Job</label><div class="sb-form-val">{{ timeModal.job?.subject }}</div></div>
<div class="sb-form-row"><label class="sb-form-lbl">Heure fixe</label><input type="time" class="sb-form-input" v-model="timeModal.time" /></div>
<div v-if="timeModal.hasPin" style="font-size:0.68rem;color:#f59e0b;margin-top:0.4rem">⚠ Heure actuellement fixée — modifier ou supprimer ci-dessous.</div>
</template>
<template #footer>
<button class="sbf-primary-btn" @click="confirmTime">📌 Fixer</button>
<button v-if="timeModal?.hasPin" class="sb-rp-btn" style="color:#ef4444" @click="clearTime">✕ Supprimer</button>
<button class="sb-rp-btn" @click="timeModal=null">Annuler</button>
</template>
</SbModal>
<!-- Move modal -->
<SbModal v-model="moveModalOpen">
<template #header><span>Déplacer la réservation</span></template>
<template v-if="moveForm">
<div class="sb-form-row"><label class="sb-form-lbl">Ticket</label><div class="sb-form-val">{{ moveForm.job?.subject }}</div></div>
<div class="sb-form-row"><label class="sb-form-lbl">Technicien actuel</label><div class="sb-form-val">{{ store.technicians.find(t=>t.id===moveForm.srcTechId)?.fullName || '—' }}</div></div>
<div class="sb-form-row"><label class="sb-form-lbl">Nouveau technicien</label>
<select class="sb-form-sel" v-model="moveForm.newTechId"><option v-for="t in store.technicians" :key="t.id" :value="t.id">{{ t.fullName }}</option></select>
</div>
<div class="sb-form-row"><label class="sb-form-lbl">Nouvelle date</label><input type="date" class="sb-form-input" v-model="moveForm.newDate" /></div>
</template>
<template #footer><button class="sbf-primary-btn" @click="confirmMove">✓ Confirmer</button><button class="sb-rp-btn" @click="moveModalOpen=false">Annuler</button></template>
</SbModal>
<!-- Resource selector modal -->
<SbModal :model-value="resSelectorOpen" @update:model-value="v => resSelectorOpen=v" :wide="true">
<template #header><span>Ressources & Groupes</span></template>
<div v-if="savedPresets.length" class="sb-rsel-groups">
<div class="sb-rsel-section-title">Sélections sauvegardées</div>
<div class="sb-rsel-chips">
<button v-for="(p, idx) in savedPresets" :key="p.name+'-'+idx" class="sb-rsel-chip sb-rsel-preset"
:class="{ active: tempSelectedIds.length && p.ids.length === tempSelectedIds.length && p.ids.every(id => tempSelectedIds.includes(id)), 'sb-rsel-preset-group': p.type === 'group' }"
@click="loadPreset(p)">
<span v-if="p.type === 'group'" class="sb-rsel-preset-icon">👥</span>
{{ p.name }} <span class="sb-rsel-preset-count">{{ p.type === 'group' ? store.technicians.filter(t => t.group === p.group && t.status !== 'inactive').length : p.ids.length }}</span>
<span class="sb-rsel-preset-del" @click.stop="deletePreset(idx)" title="Supprimer">✕</span>
</button>
</div>
</div>
<div class="sb-rsel-groups">
<div class="sb-rsel-section-title">Groupes</div>
<div class="sb-rsel-chips">
<button class="sb-rsel-chip" :class="{ active: !resSelectorGroupFilter }" @click="resSelectorGroupFilter=''">Tous</button>
<span v-for="g in availableGroups" :key="g" class="sb-rsel-group-wrap">
<button class="sb-rsel-chip"
:class="{ active: resSelectorGroupFilter === g }" @click="resSelectorGroupFilter = resSelectorGroupFilter === g ? '' : g">{{ g }}
<span class="sb-rsel-group-count">{{ store.technicians.filter(t => t.group === g && t.status !== 'inactive').length }}</span>
</button>
<button v-if="!savedPresets.some(p => p.type === 'group' && p.group === g)"
class="sb-rsel-save-group" @click.stop="saveGroupAsPreset(g)" title="Sauvegarder ce groupe">💾</button>
</span>
</div>
<div class="sb-rsel-group-actions">
<button v-if="resSelectorGroupFilter" class="sb-rsel-apply-group" @click="applyGroupFilter">
Afficher seulement « {{ resSelectorGroupFilter }} »
</button>
</div>
</div>
<div class="sb-rsel-search-row">
<input v-model="resSelectorSearch" class="sb-rsel-search" placeholder="🔍 Rechercher une ressource…" />
</div>
<div class="sb-res-sel-wrap">
<div class="sb-res-sel-col">
<div class="sb-res-sel-hdr">Disponibles</div>
<template v-for="group in resSelectorGroupsFiltered.available" :key="'avail-'+group.name">
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }} <span class="sbf-count">{{ group.techs.length }}</span></div>
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item" @click="toggleTempRes(t.id)">
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
<span class="sb-res-sel-name">{{ t.fullName }}</span>
<span v-if="t.resourceCategory" class="sb-res-sel-cat-tag">{{ t.resourceCategory }}</span>
<span v-else-if="t.group" class="sb-res-sel-grp-tag">{{ t.group }}</span>
</div>
</template>
<div v-if="!resSelectorGroupsFiltered.available.flatMap(g=>g.techs).length" class="sbf-empty">Toutes sélectionnées</div>
</div>
<div class="sb-res-sel-arrow">→</div>
<div class="sb-res-sel-col">
<div class="sb-res-sel-hdr">Sélectionnées <span class="sbf-count">{{ tempSelectedIds.length || 'Toutes' }}</span></div>
<template v-for="group in resSelectorGroupsFiltered.selected" :key="'sel-'+group.name">
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }}</div>
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item sb-res-sel-active" @click="toggleTempRes(t.id)">
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
<span class="sb-res-sel-name">{{ t.fullName }}</span><span class="sb-res-sel-rm">✕</span>
</div>
</template>
<div v-if="!tempSelectedIds.length" class="sbf-empty">Toutes affichées</div>
</div>
</div>
<template #footer>
<button class="sbf-primary-btn" @click="applyResSelector">Appliquer</button>
<template v-if="tempSelectedIds.length">
<button v-if="!showPresetSave" class="sb-rp-btn" @click="showPresetSave=true">💾 Sauvegarder</button>
<div v-else class="sb-rsel-save-row">
<input v-model="presetNameInput" class="sb-rsel-save-input" placeholder="Nom du groupe…"
@keyup.enter="savePreset" @keyup.escape="showPresetSave=false" />
<button class="sb-rsel-save-btn" @click="savePreset" :disabled="!presetNameInput.trim()">✓</button>
<button class="sb-rp-btn" style="padding:0.2rem 0.4rem" @click="showPresetSave=false">✕</button>
</div>
<button class="sb-rp-btn" @click="tempSelectedIds=[]">Tout désélectionner</button>
</template>
<button class="sb-rp-btn" @click="resSelectorOpen=false">Annuler</button>
</template>
</SbModal>
<UnifiedCreateModal v-model="woModalOpen" mode="work-order"
:context="woModalCtx"
:technicians="store.technicians"
:external-tags="store.allTags"
:external-get-color="getTagColor"
:submit-handler="confirmWo" />
<JobEditModal v-model="editModal" @confirm="confirmEdit" />
<PublishScheduleModal v-model="publishModalOpen"
:jobs="store.jobs" :technicians="filteredResources"
:period-start="periodStart" :period-end="periodEndStr"
@published="onPublished" />
<!-- Dispatch criteria modal -->
<SbModal :model-value="dispatchCriteriaModal" @update:model-value="v => dispatchCriteriaModal=v">
<template #header><span>⚙ Critères de dispatch automatique</span></template>
<p style="font-size:0.65rem;color:var(--sb-muted);margin:0 0 0.5rem">Glissez pour réordonner. Les critères du haut ont plus de poids.</p>
<div v-for="(c, i) in dispatchCriteria" :key="c.id" class="sb-crit-row"
draggable="true"
:class="{ 'sb-crit-drag-over': critDragOver === i }"
@dragstart="critDragIdx = i; $event.dataTransfer.effectAllowed = 'move'"
@dragend="critDragIdx = null; critDragOver = null"
@dragover.prevent="critDragOver = i"
@dragleave="critDragOver === i && (critDragOver = null)"
@drop.prevent="dropCriterion(i)">
<span class="sb-crit-handle" title="Glisser">⠿</span>
<span class="sb-crit-order">{{ i + 1 }}</span>
<label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label>
<div class="sb-crit-arrows">
<button :disabled="i===0" @click="moveCriterion(i,-1)">▲</button>
<button :disabled="i===dispatchCriteria.length-1" @click="moveCriterion(i,1)">▼</button>
</div>
</div>
<template #footer><button class="sbf-primary-btn" @click="saveDispatchCriteria">✓ Enregistrer</button><button class="sb-rp-btn" @click="dispatchCriteriaModal=false">Annuler</button></template>
</SbModal>
<!-- GPS settings modal (custom structure, not using SbModal) -->
<div v-if="gpsSettingsOpen" class="sb-modal-overlay" @click.self="gpsSettingsOpen=false">
<div class="sb-gps-modal">
<div class="sb-gps-modal-hdr">
<h3>📡 GPS Tracking — Traccar</h3>
<button class="sb-gps-close" @click="gpsSettingsOpen=false">&times;</button>
</div>
<div class="sb-gps-modal-body">
<p class="sb-gps-desc">Gérer les ressources dispatch, associer les devices Traccar et suivre le GPS en temps réel.</p>
<div class="sb-gps-toggle-row">
<label class="sb-gps-toggle-label">
<input type="checkbox" v-model="gpsShowInactive" />
Afficher les inactifs
<span v-if="inactiveCount" class="sb-gps-inactive-count">({{ inactiveCount }})</span>
</label>
</div>
<table class="sb-gps-table">
<thead><tr><th>Technicien</th><th>Groupe</th><th>Téléphone</th><th>Statut</th><th>Device GPS</th><th>GPS</th><th></th></tr></thead>
<tbody>
<tr v-for="tech in gpsFilteredTechs" :key="tech.id" :class="{ 'sb-gps-inactive-row': !tech.active }">
<td>
<input v-if="editingTech === tech.id" class="sb-gps-input sb-gps-edit-name" :value="tech.fullName"
@blur="saveTechField(tech, 'full_name', $event.target.value); editingTech = null"
@keydown.enter="$event.target.blur()" @keydown.escape="editingTech = null" />
<strong v-else @dblclick="editingTech = tech.id" class="sb-gps-editable" title="Double-clic pour modifier">{{ tech.fullName }}</strong>
</td>
<td>
<input class="sb-gps-input" :value="tech.group" placeholder="Ex: Terrain, Support..." style="width:100px"
@blur="saveTechGroup(tech, $event.target.value)" @keydown.enter="$event.target.blur()" list="tech-groups" />
</td>
<td><input class="sb-gps-input sb-gps-phone" :value="tech.phone" placeholder="514..." @blur="saveTechField(tech, 'phone', $event.target.value)" @keydown.enter="$event.target.blur()" /></td>
<td>
<select :value="tech.status || 'available'" @change="onTechStatusChange(tech, $event.target.value)" class="sb-gps-select sb-gps-status-sel">
<option v-for="s in [['available','Disponible'],['busy','Occupé'],['off','Absent'],['inactive','Inactif']]" :key="s[0]" :value="s[0]">{{ s[1] }}</option>
</select>
<div v-if="tech.absenceReason && !tech.active" class="sb-gps-absence-info">
{{ ABSENCE_REASONS.find(r => r.value === tech.absenceReason)?.icon || '📋' }}
{{ ABSENCE_REASONS.find(r => r.value === tech.absenceReason)?.label || tech.absenceReason }}
<span v-if="tech.absenceUntil" class="sb-gps-absence-date">→ {{ tech.absenceUntil }}</span>
</div>
</td>
<td>
<select :value="tech.traccarDeviceId || ''" @change="saveTraccarLink(tech, $event.target.value)" class="sb-gps-select">
<option value="">— Non lié —</option>
<option v-for="d in store.traccarDevices" :key="d.id" :value="String(d.id)">{{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }}</option>
</select>
</td>
<td>
<span v-if="tech.gpsCoords" class="sb-gps-badge sb-gps-online" :title="tech.gpsCoords[1].toFixed(4)+', '+tech.gpsCoords[0].toFixed(4)">
En ligne<span v-if="tech.gpsSpeed > 1"> · {{ (tech.gpsSpeed * 1.852).toFixed(0) }}km/h</span>
</span>
<span v-else-if="tech.traccarDeviceId" class="sb-gps-badge sb-gps-offline">Hors ligne</span>
<span v-else class="sb-gps-badge sb-gps-none">—</span>
</td>
<td class="sb-gps-actions">
<button v-if="tech.active" class="sb-gps-absence-btn" @click="openAbsenceModal(tech)" title="Mettre en absence">⏸</button>
<button v-else class="sb-gps-react-btn" @click="endAbsence(tech)" title="Réactiver">▶</button>
</td>
</tr>
<tr class="sb-gps-add-row">
<td><input v-model="newTechName" class="sb-gps-input" placeholder="Nom complet" @keydown.enter="addTech({ tech_group: newTechGroup.trim() || '' }); newTechGroup = ''" /></td>
<td><input v-model="newTechGroup" class="sb-gps-input" placeholder="Groupe..." list="tech-groups" style="width:100px" /></td>
<td><input v-model="newTechPhone" class="sb-gps-input sb-gps-phone" placeholder="514..." /></td>
<td></td>
<td>
<select v-model="newTechDevice" class="sb-gps-select">
<option value="">— Aucun —</option>
<option v-for="d in store.traccarDevices" :key="d.id" :value="String(d.id)">{{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }}</option>
</select>
</td>
<td colspan="2">
<button class="sb-gps-add-btn" @click="addTech({ tech_group: newTechGroup.trim() || '' }); newTechGroup = ''" :disabled="!newTechName.trim() || addingTech">{{ addingTech ? '...' : '+ Ajouter' }}</button>
</td>
</tr>
</tbody>
</table>
<datalist id="tech-groups">
<option v-for="g in availableGroups" :key="g" :value="g" />
</datalist>
<div class="sb-gps-footer">
<span class="sb-gps-info">{{ store.traccarDevices.length }} devices Traccar · {{ store.technicians.length }} techniciens · {{ availableGroups.length }} groupes</span>
</div>
</div>
</div>
</div>
<!-- Absence modal -->
<SbModal :model-value="absenceModalOpen" @update:model-value="v => absenceModalOpen=v" overlay-class="sb-overlay-top" modal-class="sb-absence-modal">
<template #header><span>⏸ Mettre en absence — {{ absenceModalTech?.fullName }}</span></template>
<div class="sb-absence-form">
<label class="sb-absence-lbl">Raison</label>
<div class="sb-absence-reasons">
<button v-for="r in ABSENCE_REASONS" :key="r.value"
class="sb-absence-reason-btn" :class="{ active: absenceForm.reason === r.value }"
@click="absenceForm.reason = r.value">
{{ r.icon }} {{ r.label }}
</button>
</div>
<div class="sb-absence-dates">
<div>
<label class="sb-absence-lbl">Du</label>
<input type="date" class="sb-form-input" v-model="absenceForm.from" />
</div>
<div>
<label class="sb-absence-lbl">Jusqu'au <span class="sb-absence-opt">(optionnel)</span></label>
<input type="date" class="sb-form-input" v-model="absenceForm.until" />
</div>
</div>
<div v-if="store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length" class="sb-absence-jobs">
<label class="sb-absence-lbl">
{{ store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length }} job(s) assigné(s)
</label>
<div class="sb-absence-job-actions">
<label class="sb-absence-radio">
<input type="radio" v-model="absenceForm.jobAction" value="unassign" />
Désassigner (retour dans le pool non assigné)
</label>
<label class="sb-absence-radio">
<input type="radio" v-model="absenceForm.jobAction" value="keep" />
Garder assignés (réassigner manuellement après)
</label>
</div>
<div class="sb-absence-job-list">
<div v-for="j in store.jobs.filter(j => j.assignedTech === absenceModalTech?.id)" :key="j.id" class="sb-absence-job-item">
<span class="sb-absence-job-dot" :style="{ background: j.priority === 'urgent' ? '#ef4444' : j.priority === 'high' ? '#f59e0b' : '#6366f1' }"></span>
{{ j.subject }} <span class="sb-absence-job-date">{{ j.scheduledDate || '—' }}</span>
</div>
</div>
</div>
<div v-else class="sb-absence-no-jobs">Aucun job assigné actuellement.</div>
</div>
<template #footer>
<button class="sbf-primary-btn" @click="confirmAbsence" :disabled="absenceProcessing">
{{ absenceProcessing ? 'En cours...' : ' Confirmer l\'absence' }}
</button>
<button class="sb-rp-btn" @click="absenceModalOpen = false">Annuler</button>
</template>
</SbModal>
<!-- Schedule editor modal -->
<div v-if="scheduleModalTech" class="sb-overlay sb-overlay-top" @click.self="scheduleModalTech = null">
<div class="sb-modal sb-schedule-modal">
<div class="sb-modal-hdr">
<span>🗓 Horaire — {{ scheduleModalTech.fullName }}</span>
<button class="sb-rp-close" @click="scheduleModalTech = null">✕</button>
</div>
<div class="sb-schedule-presets">
<button v-for="p in SCHEDULE_PRESETS" :key="p.key" class="sb-preset-btn" @click="applySchedulePreset(p)">{{ p.label }}</button>
</div>
<div class="sb-schedule-grid">
<div v-for="d in WEEK_DAYS" :key="d" class="sb-schedule-day" :class="{ 'sb-schedule-off': !scheduleForm[d]?.on }">
<label class="sb-schedule-toggle">
<input type="checkbox" v-model="scheduleForm[d].on" />
<span class="sb-schedule-day-label">{{ DAY_LABELS[d] }}</span>
</label>
<template v-if="scheduleForm[d]?.on">
<input type="time" v-model="scheduleForm[d].start" class="sb-schedule-time" />
<span class="sb-schedule-sep">→</span>
<input type="time" v-model="scheduleForm[d].end" class="sb-schedule-time" />
<span class="sb-schedule-hours">{{ ((timeToH(scheduleForm[d].end) - timeToH(scheduleForm[d].start)) || 0).toFixed(1) }}h</span>
</template>
<span v-else class="sb-schedule-off-label">Repos</span>
</div>
</div>
<!-- On-call / garde shifts -->
<div class="sb-extra-shifts-section">
<div class="sb-extra-shifts-hdr">
<span>🔔 Shifts de garde / urgence</span>
<button class="sb-rp-btn sb-rp-btn-sm" @click="addExtraShift">+ Ajouter</button>
</div>
<div v-if="!extraShiftsForm.length" class="sb-extra-shifts-empty">
Aucun shift de garde. Ajoutez-en pour planifier les disponibilités hors-horaire.
</div>
<div v-for="(shift, idx) in extraShiftsForm" :key="idx" class="sb-extra-shift-row">
<input v-model="shift.label" class="sb-extra-shift-label" placeholder="Label (ex: Garde)" />
<input type="time" v-model="shift.startTime" class="sb-schedule-time" />
<span class="sb-schedule-sep">→</span>
<input type="time" v-model="shift.endTime" class="sb-schedule-time" />
<div class="sb-extra-shift-recurrence">
<RecurrenceSelector
:model-value="shift.rrule || ''"
:ref-date="shift.from || todayStr"
:show-none="false"
@update:model-value="rrule => { shift.rrule = rrule; const p = _parseShiftPattern(rrule); shift._pattern = p.pattern; shift._interval = p.interval }"
/>
</div>
<input type="date" v-model="shift.from" class="sb-schedule-time" title="Début de la récurrence" />
<button class="sb-extra-shift-del" @click="removeExtraShift(idx)" title="Supprimer">✕</button>
</div>
</div>
<div class="sb-modal-footer">
<button class="sb-rp-btn" @click="scheduleModalTech = null">Annuler</button>
<button class="sb-rp-btn sb-rp-primary" @click="confirmSchedule">Enregistrer</button>
</div>
</div>
</div>
<!-- Confirm Unassign Dialog -->
<div v-if="confirmUnassignDialog" class="sb-modal-overlay" @click.self="cancelUnassign">
<div class="sb-confirm-dialog">
<div class="sb-confirm-icon">⚠️</div>
<div class="sb-confirm-title">Désaffecter ce job ?</div>
<div class="sb-confirm-body">
<strong>{{ pendingUnassignJob?.subject || pendingUnassignJob?.id }}</strong><br>
<span v-if="pendingUnassignJob?.published" class="sb-confirm-tag sb-confirm-tag-pub">Publié</span>
<span v-if="pendingUnassignJob?.status === 'in_progress' || pendingUnassignJob?.status === 'In Progress'" class="sb-confirm-tag sb-confirm-tag-ip">En cours</span>
<span v-if="pendingUnassignJob?.status === 'assigned'" class="sb-confirm-tag sb-confirm-tag-asg">Assigné</span>
<br><span class="sb-confirm-warn">Le technicien a déjà reçu cette tâche. Désaffecter la remettra dans le pool non-assigné.</span>
</div>
<div class="sb-confirm-actions">
<button class="sb-rp-btn" @click="cancelUnassign">Annuler</button>
<button class="sb-rp-btn sb-confirm-danger" @click="confirmUnassign">Désaffecter</button>
</div>
</div>
</div>
<!-- Create Offer Modal -->
<CreateOfferModal
v-model="createOfferModal"
:technicians="store.technicians"
:all-tags="store.allTags"
:prefill="createOfferPrefill"
@create="onCreateOffer"
/>
</div>
</template>
<style src="./dispatch-styles.scss"></style>