- Planning mode toggle: shift availability as background blocks on timeline (week view shows green=available, yellow=on-call; month view per-tech) - On-call/guard shift editor with RRULE recurrence on tech schedules - Uber-style job offer pool: broadcast/targeted/pool modes with pricing, SMS notifications, accept/decline flow, overload detection alerts - Shared resource group presets via ERPNext Dispatch Preset doctype (replaces localStorage, shared between supervisors) - Google Calendar-style RecurrenceSelector component with contextual quick options + custom RRULE editor, integrated in booking overlay and extra shift editor - Remove default "Repos" ghost chips — only visible in planning mode - Clean up debug console.logs across API, store, and page layers - Add extra_shifts Custom Field on Dispatch Technician doctype Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
390 lines
17 KiB
JavaScript
390 lines
17 KiB
JavaScript
// ── Scheduling logic: timeline computation, route cache, job placement ───────
|
||
import { ref, computed } from 'vue'
|
||
import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate, techDaySchedule, techDayCapacityH, expandRRule } from './useHelpers'
|
||
import { ABSENCE_REASONS } from './useTechManagement'
|
||
|
||
export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) {
|
||
// Day view: 6AM–6PM. Week view: 7AM–8PM.
|
||
const H_START = computed(() => currentView.value === 'day' ? 6 : 7)
|
||
const H_END = computed(() => currentView.value === 'day' ? 18 : 20)
|
||
|
||
// ── Route cache ────────────────────────────────────────────────────────────
|
||
const routeLegs = ref({})
|
||
const routeGeometry = ref({})
|
||
|
||
// ── Ghost occurrences from recurring templates ─────────────────────────────
|
||
function ghostOccurrencesForDate (tech, dateStr) {
|
||
const templates = tech.queue.filter(j => j.isRecurring && j.recurrenceRule)
|
||
if (!templates.length) return []
|
||
const ghosts = []
|
||
for (const tpl of templates) {
|
||
const rangeEnd = tpl.recurrenceEnd || localDateStr((() => { const d = new Date(); d.setDate(d.getDate() + 90); return d })())
|
||
const dates = expandRRule(tpl.recurrenceRule, tpl.scheduledDate, dateStr, dateStr, tpl.pausePeriods || [])
|
||
if (!dates.includes(dateStr)) continue
|
||
// Skip if dateStr is the template's own scheduledDate (already rendered as real job)
|
||
if (dateStr === tpl.scheduledDate) continue
|
||
// Skip if a materialized instance already exists for this date
|
||
const hasMaterialized = tech.queue.some(j => j.templateId === tpl.id && j.scheduledDate === dateStr)
|
||
if (hasMaterialized) continue
|
||
// Skip tech off-days (unless continuous)
|
||
if (!tpl.continuous && !techDaySchedule(tech, dateStr)) continue
|
||
ghosts.push({
|
||
...tpl,
|
||
id: `ghost-${tpl.id}-${dateStr}`,
|
||
_realId: tpl.id,
|
||
scheduledDate: dateStr,
|
||
endDate: null,
|
||
_isGhost: true,
|
||
_templateJob: tpl,
|
||
})
|
||
}
|
||
return ghosts
|
||
}
|
||
|
||
// ── Parent start position cache ────────────────────────────────────────────
|
||
let _parentStartCache = {}
|
||
|
||
function getParentStartH (job) {
|
||
if (!store.technicians.length) return job.startHour ?? 8
|
||
const key = `${job.assignedTech}||${job.id}`
|
||
if (_parentStartCache[key] !== undefined) return _parentStartCache[key]
|
||
const leadTech = store.technicians.find(t => t.id === job.assignedTech)
|
||
if (!leadTech) return job.startHour ?? 8
|
||
const dayStr = localDateStr(periodStart.value)
|
||
const leadJobs = sortJobsByTime(leadTech.queue.filter(j => getJobDate(j.id) === dayStr))
|
||
const cacheKey = `${leadTech.id}||${dayStr}`
|
||
const legMins = routeLegs.value[cacheKey]
|
||
const hasHome = !!(leadTech.coords?.[0] && leadTech.coords?.[1])
|
||
const _parentSched = techDaySchedule(leadTech, dayStr)
|
||
let cursor = _parentSched ? _parentSched.startH : 8, result = job.startHour ?? cursor
|
||
leadJobs.forEach((j, idx) => {
|
||
const showTravel = idx > 0 || (idx === 0 && hasHome)
|
||
if (showTravel) {
|
||
const legIdx = hasHome ? idx : idx - 1
|
||
const routeMin = legMins?.[legIdx]
|
||
cursor += (routeMin != null ? routeMin : (parseFloat(j.legDur) > 0 ? parseFloat(j.legDur) : 20)) / 60
|
||
}
|
||
const pinnedH = j.startTime ? timeToH(j.startTime) : null
|
||
const startH = pinnedH ?? cursor
|
||
if (j.id === job.id) result = startH
|
||
cursor = startH + (parseFloat(j.duration) || 1)
|
||
})
|
||
_parentStartCache[key] = result
|
||
return result
|
||
}
|
||
|
||
// ── All jobs for a tech on a date (primary + assists) ──────────────────────
|
||
function techAllJobsForDate (tech, dateStr) {
|
||
_parentStartCache = {}
|
||
const primary = tech.queue.filter(j => jobSpansDate(j, dateStr, tech))
|
||
const assists = (tech.assistJobs || [])
|
||
.filter(j => jobSpansDate(j, dateStr, tech))
|
||
.map(j => {
|
||
const a = j.assistants.find(x => x.techId === tech.id)
|
||
const parentH = getParentStartH(j)
|
||
return {
|
||
...j,
|
||
duration: a?.duration || j.duration,
|
||
startTime: hToTime(parentH),
|
||
startHour: parentH,
|
||
_isAssist: true,
|
||
_assistPinned: !!a?.pinned,
|
||
_assistNote: a?.note || '',
|
||
_parentJob: j,
|
||
}
|
||
})
|
||
const ghosts = ghostOccurrencesForDate(tech, dateStr)
|
||
return sortJobsByTime([...primary, ...assists, ...ghosts])
|
||
}
|
||
|
||
// ── Absence / schedule-off segments for a tech on a given date ───────────────
|
||
function absenceSegmentsForDate (tech, dateStr) {
|
||
const segs = []
|
||
|
||
// 1. Explicit absence (vacation, sick, etc.)
|
||
if (tech.status === 'off' && tech.absenceFrom) {
|
||
const from = tech.absenceFrom
|
||
const until = tech.absenceUntil || from
|
||
if (dateStr >= from && dateStr <= until) {
|
||
const startH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : H_START.value
|
||
const endH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : H_END.value
|
||
const reasonObj = ABSENCE_REASONS.find(r => r.value === tech.absenceReason) || { label: 'Absent', icon: '⏸' }
|
||
const left = (startH - H_START.value) * pxPerHr.value
|
||
const width = (endH - startH) * pxPerHr.value
|
||
segs.push({
|
||
type: 'absence', startH, endH,
|
||
reason: tech.absenceReason, reasonLabel: reasonObj.label, reasonIcon: reasonObj.icon,
|
||
from: tech.absenceFrom, until: tech.absenceUntil, techId: tech.id,
|
||
style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' },
|
||
job: { id: `absence-${tech.id}-${dateStr}` },
|
||
})
|
||
return segs // absence covers the day — skip schedule check
|
||
}
|
||
}
|
||
|
||
// 2. Weekly schedule off-day (regular day off like Fridays for 4×10 schedule)
|
||
// Marked as _isDayOff so TimelineRow can hide it when planning mode is off
|
||
const daySched = techDaySchedule(tech, dateStr)
|
||
if (!daySched) {
|
||
const left = 0
|
||
const width = (H_END.value - H_START.value) * pxPerHr.value
|
||
segs.push({
|
||
type: 'absence', startH: H_START.value, endH: H_END.value,
|
||
reason: 'day_off', reasonLabel: 'Jour de repos', reasonIcon: '📅',
|
||
_isDayOff: true,
|
||
from: null, until: null, techId: tech.id,
|
||
style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' },
|
||
job: { id: `schedoff-${tech.id}-${dateStr}` },
|
||
})
|
||
}
|
||
|
||
return segs
|
||
}
|
||
|
||
// ── Day view: schedule blocks with pinned anchors + auto-flow ──────────────
|
||
function techDayJobsWithTravel (tech) {
|
||
_checkCacheInvalidation()
|
||
const dayStr = localDateStr(periodStart.value)
|
||
const segKey = `${tech.id}||${dayStr}||${tech.queue.length}||${(tech.assistJobs||[]).length}`
|
||
if (_daySegmentsCache.has(segKey)) return _daySegmentsCache.get(segKey)
|
||
const cacheKey = `${tech.id}||${dayStr}`
|
||
const legMins = routeLegs.value[cacheKey]
|
||
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1])
|
||
const allJobs = techAllJobsForDate(tech, dayStr)
|
||
|
||
const flowEntries = []
|
||
const floatingEntries = []
|
||
allJobs.forEach(job => {
|
||
const isAssist = !!job._isAssist
|
||
const dur = parseFloat(job.duration) || 1
|
||
const isPinned = isAssist ? !!job._assistPinned : !!getJobTime(job.id)
|
||
const pinH = isAssist ? job.startHour : (getJobTime(job.id) ? timeToH(getJobTime(job.id)) : null)
|
||
const entry = { job, dur, isAssist, isPinned, pinH }
|
||
if (isAssist && !job._assistPinned) floatingEntries.push(entry)
|
||
else flowEntries.push(entry)
|
||
})
|
||
|
||
// Absence blocks act as occupied time (prevent auto-placing jobs there)
|
||
const absSegs = absenceSegmentsForDate(tech, dayStr)
|
||
const absOccupied = absSegs.map(s => ({ start: s.startH, end: s.endH }))
|
||
|
||
const pinnedAnchors = flowEntries.filter(e => e.isPinned).map(e => ({ start: e.pinH, end: e.pinH + e.dur }))
|
||
const placed = []
|
||
const occupied = [...pinnedAnchors.map(a => ({ ...a })), ...absOccupied]
|
||
const sortedFlow = [...flowEntries].sort((a, b) => {
|
||
if (a.isPinned && b.isPinned) return a.pinH - b.pinH
|
||
if (a.isPinned) return -1
|
||
if (b.isPinned) return 1
|
||
return 0
|
||
})
|
||
|
||
sortedFlow.filter(e => e.isPinned).forEach(e => placed.push({ entry: e, startH: e.pinH }))
|
||
|
||
const daySched = techDaySchedule(tech, dayStr)
|
||
let cursor = daySched ? daySched.startH : 8, flowIdx = 0
|
||
sortedFlow.filter(e => !e.isPinned).forEach(e => {
|
||
const legIdx = hasHome ? flowIdx : flowIdx - 1
|
||
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
|
||
const travelH = (routeMin != null ? routeMin : (parseFloat(e.job.legDur) > 0 ? parseFloat(e.job.legDur) : 20)) / 60
|
||
let startH = cursor + (flowIdx > 0 || hasHome ? travelH : 0)
|
||
let safe = false
|
||
while (!safe) {
|
||
const endH = startH + e.dur
|
||
const overlap = occupied.find(o => startH < o.end && endH > o.start)
|
||
if (overlap) startH = overlap.end + travelH
|
||
else safe = true
|
||
}
|
||
placed.push({ entry: e, startH })
|
||
occupied.push({ start: startH, end: startH + e.dur })
|
||
cursor = startH + e.dur
|
||
flowIdx++
|
||
})
|
||
|
||
// Inject absence blocks as pseudo-placed entries so travel chains around them
|
||
absSegs.forEach(s => {
|
||
placed.push({ entry: { job: s.job, dur: s.endH - s.startH, isAssist: false, isPinned: true, pinH: s.startH, _isAbsence: true, _absSeg: s }, startH: s.startH })
|
||
})
|
||
|
||
placed.sort((a, b) => a.startH - b.startH)
|
||
|
||
const result = []
|
||
|
||
// Shift availability background block (renders behind jobs)
|
||
if (daySched) {
|
||
const shiftLeft = (daySched.startH - H_START.value) * pxPerHr.value
|
||
const shiftWidth = (daySched.endH - daySched.startH) * pxPerHr.value
|
||
result.push({
|
||
type: 'shift', startH: daySched.startH, endH: daySched.endH,
|
||
label: `${daySched.start} – ${daySched.end}`,
|
||
style: { left: shiftLeft + 'px', width: Math.max(18, shiftWidth) + 'px', top: '0', bottom: '0', position: 'absolute' },
|
||
})
|
||
}
|
||
|
||
// Extra shifts (on-call, garde) from tech.extraShifts
|
||
const extras = (tech.extraShifts || []).filter(s => {
|
||
if (!s.rrule || !s.startTime || !s.endTime) return false
|
||
const dates = expandRRule(s.rrule, s.from || tech.scheduledDate || dayStr, dayStr, dayStr, [])
|
||
return dates.includes(dayStr)
|
||
})
|
||
extras.forEach(s => {
|
||
const sH = timeToH(s.startTime), eH = timeToH(s.endTime)
|
||
result.push({
|
||
type: 'shift', startH: sH, endH: eH,
|
||
label: `${s.label || 'Garde'}: ${s.startTime} – ${s.endTime}`,
|
||
isOnCall: true,
|
||
style: { left: (sH - H_START.value) * pxPerHr.value + 'px', width: Math.max(18, (eH - sH) * pxPerHr.value) + 'px', top: '0', bottom: '0', position: 'absolute' },
|
||
})
|
||
})
|
||
|
||
let prevEndH = null
|
||
let legCounter = 0
|
||
placed.forEach((p) => {
|
||
const { entry, startH } = p
|
||
const { job, dur, isAssist, isPinned, _isAbsence, _absSeg } = entry
|
||
|
||
// Travel gap between previous block end and this block start
|
||
const travelStart = prevEndH ?? (hasHome ? 8 : null)
|
||
if (travelStart != null && startH > travelStart + 0.01 && !_isAbsence) {
|
||
const gapH = startH - travelStart
|
||
const realJob = isAssist ? job._parentJob : job
|
||
const legIdx = hasHome ? legCounter : legCounter - 1
|
||
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
|
||
const fromRoute = routeMin != null
|
||
result.push({
|
||
type: 'travel', job: realJob, travelMin: fromRoute ? routeMin : Math.round(gapH * 60), fromRoute, isAssist: false,
|
||
style: { left: (travelStart - H_START.value) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' },
|
||
color: jobColorFn(realJob),
|
||
})
|
||
legCounter++
|
||
}
|
||
|
||
if (_isAbsence) {
|
||
// Render as absence block
|
||
result.push(_absSeg)
|
||
} else {
|
||
const realJob = isAssist ? job._parentJob : job
|
||
const jLeft = (startH - H_START.value) * pxPerHr.value
|
||
const jWidth = Math.max(18, dur * pxPerHr.value)
|
||
result.push({
|
||
type: isAssist ? 'assist' : 'job', job: realJob,
|
||
pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist,
|
||
assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null,
|
||
assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null,
|
||
_isGhost: !!job._isGhost, _templateJob: job._templateJob || null,
|
||
style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' },
|
||
})
|
||
}
|
||
prevEndH = startH + dur
|
||
})
|
||
|
||
floatingEntries.forEach(entry => {
|
||
const { job, dur } = entry
|
||
const startH = job.startHour ?? 8
|
||
result.push({
|
||
type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true,
|
||
assistPinned: false, assistDur: dur, assistNote: job._assistNote, assistTechId: tech.id,
|
||
style: { left: (startH - H_START.value) * pxPerHr.value + 'px', width: Math.max(18, dur * pxPerHr.value) + 'px', top: '52%', bottom: '4px', position: 'absolute' },
|
||
})
|
||
})
|
||
|
||
_daySegmentsCache.set(segKey, result)
|
||
return result
|
||
}
|
||
|
||
// ── Week view helpers ──────────────────────────────────────────────────────
|
||
function techBookingsByDay (tech) {
|
||
return dayColumns.value.map(d => {
|
||
const ds = localDateStr(d)
|
||
const primary = tech.queue.filter(j => jobSpansDate(j, ds, tech))
|
||
const assists = (tech.assistJobs || [])
|
||
.filter(j => jobSpansDate(j, ds, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned)
|
||
.map(j => ({ ...j, _isAssistChip: true, _assistDur: j.assistants.find(a => a.techId === tech.id)?.duration || j.duration }))
|
||
const ghosts = ghostOccurrencesForDate(tech, ds)
|
||
const absSegs = absenceSegmentsForDate(tech, ds)
|
||
return { day: d, dateStr: ds, jobs: [...primary, ...assists, ...ghosts], absent: absSegs.length > 0, absenceInfo: absSegs[0] || null }
|
||
})
|
||
}
|
||
|
||
// ── Memoization caches (cleared on reactive dependency changes) ──────────
|
||
const _periodLoadCache = new Map()
|
||
const _periodCapCache = new Map()
|
||
const _daySegmentsCache = new Map()
|
||
|
||
// Invalidate caches when period/view changes
|
||
let _lastCacheKey = ''
|
||
function _checkCacheInvalidation () {
|
||
const key = `${currentView.value}||${periodStart.value}||${dayColumns.value.length}||${store.jobs.length}||${store.jobVersion}`
|
||
if (key !== _lastCacheKey) {
|
||
_lastCacheKey = key
|
||
_periodLoadCache.clear()
|
||
_periodCapCache.clear()
|
||
_daySegmentsCache.clear()
|
||
}
|
||
}
|
||
|
||
function periodLoadH (tech) {
|
||
_checkCacheInvalidation()
|
||
if (_periodLoadCache.has(tech.id)) return _periodLoadCache.get(tech.id)
|
||
const dateSet = new Set(dayColumns.value.map(d => localDateStr(d)))
|
||
let total = tech.queue.reduce((sum, j) => {
|
||
const ds = getJobDate(j.id)
|
||
return ds && dateSet.has(ds) ? sum + (parseFloat(j.duration) || 0) : sum
|
||
}, 0)
|
||
;(tech.assistJobs || []).forEach(j => {
|
||
const ds = getJobDate(j.id)
|
||
if (ds && dateSet.has(ds)) {
|
||
const a = j.assistants.find(x => x.techId === tech.id)
|
||
if (a?.pinned) total += parseFloat(a?.duration || j.duration) || 0
|
||
}
|
||
})
|
||
// Include absence hours as occupied time
|
||
dayColumns.value.forEach(d => {
|
||
const ds = localDateStr(d)
|
||
const absSegs = absenceSegmentsForDate(tech, ds)
|
||
absSegs.forEach(s => { total += s.endH - s.startH })
|
||
})
|
||
_periodLoadCache.set(tech.id, total)
|
||
return total
|
||
}
|
||
|
||
// Per-tech capacity for the visible period (for utilization bar denominator)
|
||
function techPeriodCapacityH (tech) {
|
||
_checkCacheInvalidation()
|
||
if (_periodCapCache.has(tech.id)) return _periodCapCache.get(tech.id)
|
||
let total = 0
|
||
dayColumns.value.forEach(d => {
|
||
total += techDayCapacityH(tech, localDateStr(d))
|
||
})
|
||
const result = total || 8 // fallback to avoid /0
|
||
_periodCapCache.set(tech.id, result)
|
||
return result
|
||
}
|
||
|
||
// Per-tech end hour for current day (for capacity line position)
|
||
function techDayEndH (tech) {
|
||
const dayStr = localDateStr(periodStart.value)
|
||
const sched = techDaySchedule(tech, dayStr)
|
||
return sched ? sched.endH : 16
|
||
}
|
||
|
||
function techsActiveOnDay (dateStr, resources) {
|
||
return resources.filter(tech =>
|
||
tech.queue.some(j => jobSpansDate(j, dateStr, tech)) ||
|
||
(tech.assistJobs || []).some(j => jobSpansDate(j, dateStr, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned)
|
||
)
|
||
}
|
||
|
||
function dayJobCount (dateStr, resources) {
|
||
const jobIds = new Set()
|
||
resources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr, t)).forEach(j => jobIds.add(j.id)))
|
||
return jobIds.size
|
||
}
|
||
|
||
return {
|
||
H_START, H_END, routeLegs, routeGeometry,
|
||
techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, ghostOccurrencesForDate,
|
||
techBookingsByDay, periodLoadH, techPeriodCapacityH, techDayEndH,
|
||
techsActiveOnDay, dayJobCount,
|
||
}
|
||
}
|