// ── 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, } }