gigafibre-fsm/apps/ops/src/composables/useScheduler.js
louispaulb 9b9d307bdf feat: dispatch planning mode, offer pool, shared presets, recurrence selector
- 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>
2026-04-08 22:44:18 -04:00

390 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// ── 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: 6AM6PM. Week view: 7AM8PM.
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,
}
}