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>
1813 lines
92 KiB
Vue
1813 lines
92 KiB
Vue
<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">×</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>
|