chore(ops): supprime le sous-système dispatch MORT (23 fichiers, 0 importeur)

Lane 4 (nettoyage) — vérifié 0 importeur vivant (aucun barrel, aucun import.meta.glob ; build OK après suppression) :
- stores/dispatch.js + 9 composables (useGpsTracking/useAutoDispatch/useBottomPanel/useJobOffers/useDragDrop/useScheduler/
  useSelection/useUndo/useResourceFilter)
- features/workforce/ (api/assignment + useAssignment + useResources)
- components/dispatch/NlpInput.vue
- 11/12 modules/dispatch/components/* (BottomPanel/CreateOfferModal/JobEditModal/MonthCalendar/OfferPoolPanel/
  PublishScheduleModal/RightPanel/SbContextMenu/SbModal/TimelineRow/WeekCalendar)
GARDÉ : modules/dispatch/components/SuggestSlotsDialog.vue (vivant — ClientDetailPage + PlanificationPage), OccupancyBands,
AvailabilityByReason, api/dispatch. Reliquat Mapbox mort (JobEditModal static image) supprimé avec le fichier.
Bundle inchangé (code déjà tree-shaké car 0 importeur) → pas de redéploiement requis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 10:32:18 -04:00
parent da9c918fa9
commit daeb86008b
25 changed files with 0 additions and 4194 deletions

View File

@ -1,166 +0,0 @@
<template>
<div class="nlp-input-bar" :class="{ 'nlp-expanded': showResult }">
<div class="nlp-row">
<q-icon name="auto_awesome" size="18px" color="green-5" class="q-mr-xs" />
<input
ref="inputRef"
v-model="text"
class="nlp-text"
:placeholder="placeholder"
@keydown.enter="submit"
@keydown.escape="clear"
:disabled="loading"
/>
<q-spinner v-if="loading" size="16px" color="green-5" class="q-mx-xs" />
<q-btn v-else-if="text.trim()" flat dense round size="sm" icon="send" color="primary" @click="submit" />
<q-btn v-if="showResult" flat dense round size="xs" icon="close" color="grey-6" @click="clear" />
</div>
<transition name="nlp-slide">
<div v-if="showResult && result" class="nlp-result">
<div class="nlp-result-action">
<q-icon :name="actionIcon" :color="actionColor" size="16px" class="q-mr-xs" />
<span class="text-weight-bold">{{ result.action_label || result.action }}</span>
<q-badge v-if="result.confidence" :color="result.confidence > 0.8 ? 'green' : result.confidence > 0.5 ? 'orange' : 'red'" class="q-ml-sm">
{{ Math.round(result.confidence * 100) }}%
</q-badge>
</div>
<div v-if="result.explanation" class="text-caption text-grey-7 q-mt-xs">{{ result.explanation }}</div>
<!-- Action-specific details -->
<div v-if="result.action === 'create_job'" class="nlp-details q-mt-sm">
<div v-if="result.subject" class="nlp-detail"><span class="nlp-label">Sujet:</span> {{ result.subject }}</div>
<div v-if="result.tech" class="nlp-detail"><span class="nlp-label">Tech:</span> {{ result.tech }}</div>
<div v-if="result.date" class="nlp-detail"><span class="nlp-label">Date:</span> {{ result.date }}</div>
<div v-if="result.time" class="nlp-detail"><span class="nlp-label">Heure:</span> {{ result.time }}</div>
<div v-if="result.duration" class="nlp-detail"><span class="nlp-label">Durée:</span> {{ result.duration }}h</div>
<div v-if="result.address" class="nlp-detail"><span class="nlp-label">Adresse:</span> {{ result.address }}</div>
</div>
<div v-if="result.action === 'move_job'" class="nlp-details q-mt-sm">
<div v-if="result.from_tech" class="nlp-detail"><span class="nlp-label">De:</span> {{ result.from_tech }}</div>
<div v-if="result.to_tech" class="nlp-detail"><span class="nlp-label">Vers:</span> {{ result.to_tech }}</div>
</div>
<div v-if="result.action === 'redistribute'" class="nlp-details q-mt-sm">
<div v-if="result.absent_tech" class="nlp-detail"><span class="nlp-label">Absent:</span> {{ result.absent_tech }}</div>
</div>
<div class="nlp-actions q-mt-sm">
<q-btn v-if="result.action !== 'query' && result.action !== 'unknown'" dense no-caps unelevated
color="primary" size="sm" icon="check" label="Appliquer" @click="apply" :loading="applying" />
<q-btn flat dense no-caps size="sm" label="Ignorer" color="grey-6" @click="clear" />
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
const props = defineProps({
techNames: { type: Array, default: () => [] },
})
const emit = defineEmits(['action', 'applied'])
const $q = useQuasar()
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
const inputRef = ref(null)
const text = ref('')
const loading = ref(false)
const applying = ref(false)
const result = ref(null)
const showResult = computed(() => !!result.value)
const placeholder = 'Ex: "Mets une install chez 123 rue Laval demain 9h pour Marc" ou "Marc est malade, redistribue"...'
const ACTION_ICONS = {
create_job: 'add_task',
move_job: 'swap_horiz',
redistribute: 'group_work',
cancel_job: 'cancel',
query: 'search',
unknown: 'help_outline',
}
const ACTION_COLORS = {
create_job: 'primary',
move_job: 'blue-6',
redistribute: 'orange-8',
cancel_job: 'red',
query: 'grey-7',
unknown: 'grey-5',
}
const actionIcon = computed(() => ACTION_ICONS[result.value?.action] || 'help_outline')
const actionColor = computed(() => ACTION_COLORS[result.value?.action] || 'grey-5')
async function submit () {
const input = text.value.trim()
if (!input || loading.value) return
loading.value = true
result.value = null
try {
const res = await fetch(`${HUB_URL}/ai/dispatch-nlp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: input, tech_names: props.techNames }),
})
if (!res.ok) throw new Error('Erreur AI')
const data = await res.json()
result.value = data
emit('action', data)
} catch (e) {
$q.notify({ type: 'negative', message: `NLP: ${e.message}` })
} finally {
loading.value = false
}
}
function apply () {
if (!result.value) return
applying.value = true
emit('applied', result.value)
// Parent handles the actual dispatch action
setTimeout(() => {
applying.value = false
clear()
}, 500)
}
function clear () {
result.value = null
text.value = ''
inputRef.value?.focus()
}
defineExpose({ focus: () => inputRef.value?.focus() })
</script>
<style scoped>
.nlp-input-bar {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
transition: all 0.2s ease;
}
.nlp-input-bar:focus-within { border-color: #818cf8; box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.15); }
.nlp-expanded { background: #fff; }
.nlp-row { display: flex; align-items: center; padding: 6px 10px; }
.nlp-text {
flex: 1; border: none; outline: none; background: transparent;
font-size: 0.84rem; color: #1e293b; font-family: inherit;
}
.nlp-text::placeholder { color: #94a3b8; }
.nlp-result { padding: 8px 12px; border-top: 1px solid #e2e8f0; }
.nlp-result-action { display: flex; align-items: center; }
.nlp-details { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.nlp-detail { font-size: 0.78rem; color: #475569; }
.nlp-label { font-weight: 600; color: #94a3b8; font-size: 0.72rem; }
.nlp-actions { display: flex; gap: 8px; }
.nlp-slide-enter-active, .nlp-slide-leave-active { transition: max-height 0.2s ease, opacity 0.15s ease; overflow: hidden; }
.nlp-slide-enter-from, .nlp-slide-leave-to { max-height: 0; opacity: 0; }
.nlp-slide-enter-to, .nlp-slide-leave-from { max-height: 300px; opacity: 1; }
</style>

View File

@ -1,139 +0,0 @@
// ── Auto-dispatch composable: autoDistribute + optimizeRoute ─────────────────
import { localDateStr } from './useHelpers'
import { updateJob } from 'src/api/dispatch'
import * as roster from 'src/api/roster' // TSP via NOTRE OSRM (plus de Mapbox optimized-trips)
export function useAutoDispatch (deps) {
const { store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs, bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes } = deps
async function autoDistribute () {
const techs = filteredResources.value
if (!techs.length) return
const today = localDateStr(new Date())
let pool
if (bottomSelected.value.size) {
pool = [...bottomSelected.value].map(id => store.jobs.find(j => j.id === id)).filter(Boolean)
} else {
pool = unscheduledJobs.value.filter(j => !j.scheduledDate || j.scheduledDate === today)
}
if (!pool.length) return
// Jobs with coords get proximity-based assignment, jobs without get load-balanced only
const withCoords = pool.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0))
const noCoords = pool.filter(j => !j.coords || (j.coords[0] === 0 && j.coords[1] === 0))
const unassigned = [...withCoords, ...noCoords]
if (!unassigned.length) return
const prevQueues = {}
techs.forEach(t => { prevQueues[t.id] = [...t.queue] })
const prevAssignments = unassigned.map(j => ({ jobId: j.id, techId: j.assignedTech, scheduledDate: j.scheduledDate }))
function techLoadForDay (tech, dayStr) {
return tech.queue.filter(j => getJobDate(j.id) === dayStr).reduce((s, j) => s + (parseFloat(j.duration) || 1), 0)
}
function dist (a, b) {
if (!a || !b) return 999
const dx = (a[0] - b[0]) * 80, dy = (a[1] - b[1]) * 111
return Math.sqrt(dx * dx + dy * dy)
}
function techLastPosForDay (tech, dayStr) {
const dj = tech.queue.filter(j => getJobDate(j.id) === dayStr)
if (dj.length) { const last = dj[dj.length - 1]; if (last.coords && last.coords[0] !== 0) return last.coords }
return tech.coords
}
const criteria = dispatchCriteria.value.filter(c => c.enabled)
const sorted = [...unassigned].sort((a, b) => {
for (const c of criteria) {
if (c.id === 'urgency') {
const p = { high: 0, medium: 1, low: 2 }
const diff = (p[a.priority] ?? 2) - (p[b.priority] ?? 2)
if (diff !== 0) return diff
}
}
return 0
})
const useSkills = criteria.some(c => c.id === 'skills')
const weights = {}
criteria.forEach((c, i) => { weights[c.id] = criteria.length - i })
sorted.forEach(job => {
const assignDay = job.scheduledDate || today
let bestTech = null, bestScore = Infinity
techs.forEach(tech => {
let score = 0
if (weights.balance) score += techLoadForDay(tech, assignDay) * (weights.balance || 1)
if (weights.proximity && job.coords && (job.coords[0] !== 0 || job.coords[1] !== 0)) score += dist(techLastPosForDay(tech, assignDay), job.coords) / 60 * (weights.proximity || 1)
if (weights.skills && useSkills) {
const jt = job.tags || [], tt = tech.tags || []
score += (jt.length > 0 ? (jt.length - jt.filter(t => tt.includes(t)).length) * 2 : 0) * (weights.skills || 1)
}
if (score < bestScore) { bestScore = score; bestTech = tech }
})
if (bestTech) store.smartAssign(job.id, bestTech.id, assignDay)
})
pushUndo({ type: 'autoDistribute', assignments: prevAssignments, prevQueues })
bottomSelected.value = new Set()
invalidateRoutes()
}
async function optimizeRoute (tech) {
const dayStr = localDateStr(periodStart.value)
const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr)
if (dayJobs.length < 2) return
const jobsWithCoords = dayJobs.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0))
if (jobsWithCoords.length < 2) return
const urgent = jobsWithCoords.filter(j => j.priority === 'high')
const normal = jobsWithCoords.filter(j => j.priority !== 'high')
function nearestNeighbor (start, jobs) {
const result = [], remaining = [...jobs]
let cur = start
while (remaining.length) {
let bi = 0, bd = Infinity
remaining.forEach((j, i) => {
const dx = j.coords[0] - cur[0], dy = j.coords[1] - cur[1], d = dx * dx + dy * dy
if (d < bd) { bd = d; bi = i }
})
result.push(remaining.splice(bi, 1)[0])
cur = result.at(-1).coords
}
return result
}
const home = (tech.coords?.[0] && tech.coords?.[1]) ? tech.coords : jobsWithCoords[0].coords
const orderedUrgent = nearestNeighbor(home, urgent)
const orderedNormal = nearestNeighbor(orderedUrgent.length ? orderedUrgent.at(-1).coords : home, normal)
const reordered = [...orderedUrgent, ...orderedNormal]
try {
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1])
const coords = []
if (hasHome) coords.push([tech.coords[0], tech.coords[1]])
reordered.forEach(j => coords.push([j.coords[0], j.coords[1]]))
if (coords.length >= 2 && coords.length <= 12) {
const data = await roster.osrmTrip(coords) // NOTRE OSRM /trip (via hub) — même forme que Mapbox optimized-trips
if (data.code === 'Ok' && data.waypoints) {
const off = hasHome ? 1 : 0, uc = orderedUrgent.length
const mu = reordered.slice(0, uc).map((j, i) => ({ job: j, o: data.waypoints[i + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job)
const mn = reordered.slice(uc).map((j, i) => ({ job: j, o: data.waypoints[i + uc + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job)
reordered.length = 0
reordered.push(...mu, ...mn)
}
}
} catch (_) {}
pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] })
const otherJobs = tech.queue.filter(j => getJobDate(j.id) !== dayStr)
tech.queue = [...reordered, ...otherJobs]
tech.queue.forEach((j, i) => {
j.routeOrder = i
updateJob(j.name || j.id, { route_order: i, start_time: '' }).catch(() => {})
})
invalidateRoutes()
}
return { autoDistribute, optimizeRoute }
}

View File

@ -1,129 +0,0 @@
// ── Bottom panel composable: unassigned jobs table, multi-select, criteria ────
import { ref, computed, watch } from 'vue'
import { localDateStr, fmtDate } from './useHelpers'
export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart }) {
const bottomPanelOpen = ref(localStorage.getItem('sbv2-bottomPanel') !== 'false')
const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220)
watch(bottomPanelOpen, v => localStorage.setItem('sbv2-bottomPanel', v ? 'true' : 'false'))
// ── Tri / regroupement (date · ville · priorité) ─────────────────────────────
const bottomSort = ref(localStorage.getItem('sbv2-bottomSort') || 'date')
watch(bottomSort, v => localStorage.setItem('sbv2-bottomSort', v))
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
// Ville : 2e segment de l'adresse libre, sinon 1er token du sujet avant « | » (tickets legacy), sinon vide.
function cityOf (job) {
const a = String(job.address || '')
const parts = a.split(',').map(s => s.trim()).filter(Boolean)
if (parts.length >= 2) return parts[1]
const subj = String(job.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim()
return parts[0] || ''
}
const unassignedGrouped = computed(() => {
const today = todayStr
const sort = bottomSort.value
const jobs = unscheduledJobs.value.slice()
const byDate = (a, b) => { // ordre secondaire : date (aujourd'hui d'abord)
const da = a.scheduledDate || '9999-99-99'; const db = b.scheduledDate || '9999-99-99'
const at = da === today ? 0 : 1; const bt = db === today ? 0 : 1
return at !== bt ? at - bt : da.localeCompare(db)
}
jobs.sort((a, b) => {
if (sort === 'priority') return (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || byDate(a, b)
if (sort === 'city') return cityOf(a).localeCompare(cityOf(b)) || byDate(a, b)
return byDate(a, b) || ((PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3))
})
const keyOf = job => sort === 'priority' ? (job.priority || 'low') : sort === 'city' ? (cityOf(job) || 'Sans ville') : (job.scheduledDate || null)
const labelOf = key => {
if (sort === 'priority') return ({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[key] || key
if (sort === 'city') return key
return key === today ? "Aujourd'hui" : (key ? fmtDate(new Date(key + 'T00:00:00')) : 'Sans date')
}
const groups = []; let cur = Symbol('init')
jobs.forEach(job => {
const k = keyOf(job)
if (k !== cur) { cur = k; groups.push({ key: String(k), date: sort === 'date' ? k : null, label: labelOf(k), jobs: [] }) }
groups.at(-1).jobs.push(job)
})
return groups
})
// ── Resize ───────────────────────────────────────────────────────────────────
function startBottomResize (e) {
e.preventDefault()
const startY = e.clientY, startH = bottomPanelH.value
function onMove (ev) { bottomPanelH.value = Math.max(100, Math.min(window.innerHeight * 0.6, startH - (ev.clientY - startY))) }
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-bottomH', String(bottomPanelH.value)) }
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
}
// ── Multi-select ─────────────────────────────────────────────────────────────
const bottomSelected = ref(new Set())
function toggleBottomSelect (jobId, event) {
const s = new Set(bottomSelected.value)
// Checkbox click: always toggle (no modifier needed)
// Shift+click: range select
if (event?.shiftKey && s.size) {
const flat = unassignedGrouped.value.flatMap(g => g.jobs)
const ids = flat.map(j => j.id)
const lastId = [...s].pop()
const fromIdx = ids.indexOf(lastId), toIdx = ids.indexOf(jobId)
if (fromIdx >= 0 && toIdx >= 0) {
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]
for (let i = lo; i <= hi; i++) s.add(ids[i])
}
} else {
// Simple toggle (no Ctrl needed)
if (s.has(jobId)) s.delete(jobId); else s.add(jobId)
}
bottomSelected.value = s
}
function selectAllBottom () { const s = new Set(); unscheduledJobs.value.forEach(j => s.add(j.id)); bottomSelected.value = s }
function clearBottomSelect () { bottomSelected.value = new Set() }
function batchAssignBottom (techId) {
const dayStr = localDateStr(periodStart.value)
bottomSelected.value.forEach(jobId => {
const job = store.jobs.find(j => j.id === jobId)
if (job) {
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
smartAssign(job, techId, dayStr)
}
})
bottomSelected.value = new Set()
invalidateRoutes()
}
// ── Dispatch criteria ────────────────────────────────────────────────────────
const defaultCriteria = [
{ id: 'urgency', label: 'Urgence (priorité haute en premier)', enabled: true },
{ id: 'balance', label: 'Équilibrage de charge (tech le moins chargé)', enabled: true },
{ id: 'proximity', label: 'Proximité géographique', enabled: true },
{ id: 'skills', label: 'Correspondance des tags/skills', enabled: false },
]
const dispatchCriteria = ref(JSON.parse(localStorage.getItem('sbv2-dispatchCriteria') || 'null') || defaultCriteria.map(c => ({ ...c })))
const dispatchCriteriaModal = ref(false)
function saveDispatchCriteria () { localStorage.setItem('sbv2-dispatchCriteria', JSON.stringify(dispatchCriteria.value)); dispatchCriteriaModal.value = false }
function moveCriterion (idx, dir) {
const arr = dispatchCriteria.value, newIdx = idx + dir
if (newIdx < 0 || newIdx >= arr.length) return
const tmp = arr[idx]; arr[idx] = arr[newIdx]; arr[newIdx] = tmp
}
// ── Column widths ────────────────────────────────────────────────────────────
const btColWidths = ref(JSON.parse(localStorage.getItem('sbv2-btColW') || '{}'))
function btColW (col, def) { return (btColWidths.value[col] || def) + 'px' }
function startColResize (e, col) {
e.preventDefault(); e.stopPropagation()
const startX = e.clientX, startW = btColWidths.value[col] || parseInt(getComputedStyle(e.target.parentElement).width)
function onMove (ev) { btColWidths.value = { ...btColWidths.value, [col]: Math.max(40, startW + (ev.clientX - startX)) } }
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-btColW', JSON.stringify(btColWidths.value)) }
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
}
return {
bottomPanelOpen, bottomPanelH, unassignedGrouped, bottomSort, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
btColWidths, btColW, startColResize,
}
}

View File

@ -1,242 +0,0 @@
// ── Drag & Drop composable: job drag, tech drag, block move, block resize, batch drag ──
import { ref } from 'vue'
import { snapH, hToTime, fmtDur, localDateStr, SNAP, serializeAssistants } from './useHelpers'
import { updateJob } from 'src/api/dispatch'
export function useDragDrop (deps) {
const {
store, pxPerHr, dayW, periodStart, periodDays, H_START,
getJobDate, bottomSelected, multiSelect,
pushUndo, smartAssign, invalidateRoutes,
} = deps
const dragJob = ref(null)
const dragSrc = ref(null)
const dragIsAssist = ref(false)
const dropGhost = ref(null)
const dragTech = ref(null)
const dragBatchIds = ref(null)
function cleanupDropIndicators () {
document.querySelectorAll('.sb-block-drop-hover').forEach(el => el.classList.remove('sb-block-drop-hover'))
dropGhost.value = null
}
function onJobDragStart (e, job, srcTechId, isAssist = false) {
dragJob.value = job; dragSrc.value = srcTechId || null; dragIsAssist.value = isAssist
if (!srcTechId && bottomSelected.value.size > 1 && bottomSelected.value.has(job.id)) {
dragBatchIds.value = new Set(bottomSelected.value)
e.dataTransfer.setData('text/plain', `batch:${dragBatchIds.value.size}`)
} else {
dragBatchIds.value = null
}
e.dataTransfer.effectAllowed = 'move'
e.target.addEventListener('dragend', () => { cleanupDropIndicators(); dragIsAssist.value = false; dragBatchIds.value = null }, { once: true })
}
function onTimelineDragOver (e, tech) {
e.preventDefault()
if (!dragJob.value && !dragTech.value) return
const x = e.clientX - e.currentTarget.getBoundingClientRect().left
dropGhost.value = { techId: tech.id, x, dateStr: xToDateStr(x) }
}
function onTimelineDragLeave (e) {
if (!e.currentTarget.contains(e.relatedTarget)) dropGhost.value = null
}
function onTechDragStart (e, tech) {
dragTech.value = tech
e.dataTransfer.effectAllowed = 'copyMove'
e.dataTransfer.setData('text/plain', tech.id)
e.target.addEventListener('dragend', () => { dragTech.value = null; cleanupDropIndicators() }, { once: true })
return tech
}
function onBlockDrop (e, job) {
if (dragTech.value) {
e.preventDefault(); e.stopPropagation()
cleanupDropIndicators()
pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' })
store.addAssistant(job.id, dragTech.value.id)
dragTech.value = null
invalidateRoutes()
}
}
function assignDroppedJob (tech, dateStr) {
if (!dragJob.value) return
if (dragIsAssist.value) {
dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return
}
if (dragBatchIds.value && dragBatchIds.value.size > 1) {
const prevStates = []
dragBatchIds.value.forEach(jobId => {
const j = store.jobs.find(x => x.id === jobId)
if (j && !j.assignedTech) {
prevStates.push({ jobId: j.id, techId: j.assignedTech, routeOrder: j.routeOrder, scheduledDate: j.scheduledDate, assistants: [...(j.assistants || [])] })
smartAssign(j, tech.id, dateStr)
}
})
if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, targetTechId: tech.id })
bottomSelected.value = new Set()
dragBatchIds.value = null
} else if (multiSelect && multiSelect.value?.length > 1 && multiSelect.value.some(s => s.job.id === dragJob.value.id)) {
// Dragging a multi-selected block from timeline — move all selected
const prevStates = []
const prevQueues = {}
store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] })
multiSelect.value.filter(s => !s.isAssist).forEach(s => {
prevStates.push({ jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder, scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])] })
smartAssign(s.job, tech.id, dateStr)
})
if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, prevQueues })
multiSelect.value = []
} else {
const job = dragJob.value
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
smartAssign(job, tech.id, dateStr)
}
dropGhost.value = null; dragJob.value = null; dragSrc.value = null
invalidateRoutes()
}
function onTimelineDrop (e, tech) {
e.preventDefault()
cleanupDropIndicators()
if (dragTech.value) {
const els = document.elementsFromPoint(e.clientX, e.clientY)
const blockEl = els.find(el => el.dataset?.jobId)
if (blockEl) {
const job = store.jobs.find(j => j.id === blockEl.dataset.jobId)
if (job) {
pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' })
store.addAssistant(job.id, dragTech.value.id)
dragTech.value = null; invalidateRoutes(); return
}
}
dragTech.value = null; return
}
if (!dragJob.value) return
if (dragJob.value.assignedTech === tech.id) {
const rect = e.currentTarget.getBoundingClientRect()
const x = (e.clientX || e.pageX) - rect.left
const dropH = H_START.value + x / pxPerHr.value
const dayStr = localDateStr(periodStart.value)
pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] })
const draggedJob = dragJob.value
tech.queue = tech.queue.filter(j => j.id !== draggedJob.id)
const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr)
const queueDayStart = tech.queue.findIndex(j => getJobDate(j.id) === dayStr)
let slot = dayJobs.length, cursor = 8
for (let i = 0; i < dayJobs.length; i++) {
const dur = parseFloat(dayJobs[i].duration) || 1
if (dropH < cursor + dur / 2) { slot = i; break }
cursor += dur + 0.5
}
const insertAt = queueDayStart >= 0 ? queueDayStart + slot : tech.queue.length
tech.queue.splice(insertAt, 0, draggedJob)
tech.queue.forEach((q, i) => { q.routeOrder = i; updateJob(q.name || q.id, { route_order: i }).catch(() => {}) })
dragJob.value = null; dragSrc.value = null; invalidateRoutes(); return
}
if (dragIsAssist.value) {
dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return
}
assignDroppedJob(tech, xToDateStr(e.clientX - e.currentTarget.getBoundingClientRect().left))
}
function onCalDrop (e, tech, dateStr) { assignDroppedJob(tech, dateStr) }
function xToDateStr (x) {
const di = Math.max(0, Math.min(periodDays.value - 1, Math.floor(x / dayW.value)))
const d = new Date(periodStart.value); d.setDate(d.getDate() + di)
return localDateStr(d)
}
function startBlockMove (e, job, block) {
if (e.button !== 0) return
const startX = e.clientX, startY = e.clientY
const startLeft = parseFloat(block.style.left) || 0
let moving = false
function onMove (ev) {
const dx = ev.clientX - startX, dy = ev.clientY - startY
if (!moving && Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 5) { cleanup(); return }
if (!moving && Math.abs(dx) > 5) { moving = true; block.style.zIndex = '10' }
if (!moving) return
ev.preventDefault()
const newLeft = Math.max(0, startLeft + dx)
const newH = snapH(H_START.value + newLeft / pxPerHr.value)
block.style.left = ((newH - H_START.value) * pxPerHr.value) + 'px'
const meta = block.querySelector('.sb-block-meta')
if (meta) meta.textContent = `${hToTime(newH)} · ${fmtDur(job.duration)}`
}
function cleanup () {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
function onUp (ev) {
cleanup()
if (!moving) return
block.style.zIndex = ''
const dx = ev.clientX - startX
const newH = snapH(H_START.value + Math.max(0, startLeft + dx) / pxPerHr.value)
job.startHour = newH; job.startTime = hToTime(newH)
store.setJobSchedule(job.id, job.scheduledDate, hToTime(newH))
invalidateRoutes()
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
function startResize (e, job, mode, assistTechId) {
e.preventDefault()
const startX = e.clientX
const startDur = mode === 'assist'
? (job.assistants.find(a => a.techId === assistTechId)?.duration || job.duration)
: job.duration
const block = e.target.parentElement
const startW = block.offsetWidth
function onMove (ev) {
const dx = ev.clientX - startX
const newDur = Math.max(SNAP, snapH(Math.max(18, startW + dx) / pxPerHr.value))
block.style.width = (newDur * pxPerHr.value) + 'px'
const meta = block.querySelector('.sb-block-meta')
if (meta) meta.textContent = mode === 'assist' ? `assistant · ${fmtDur(newDur)}` : fmtDur(newDur)
}
function onUp (ev) {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
const dx = ev.clientX - startX
const newDur = Math.max(SNAP, snapH(Math.max(18, startW + dx) / pxPerHr.value))
if (mode === 'assist' && assistTechId) {
const assist = job.assistants.find(a => a.techId === assistTechId)
if (assist) {
assist.duration = newDur
updateJob(job.name || job.id, {
assistants: serializeAssistants(job.assistants),
}).catch(() => {})
}
} else {
job.duration = newDur
updateJob(job.name || job.id, { duration_h: newDur }).catch(() => {})
}
invalidateRoutes()
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
return {
dragJob, dragSrc, dragIsAssist, dropGhost, dragTech, dragBatchIds,
cleanupDropIndicators,
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
onTechDragStart, onBlockDrop,
assignDroppedJob, onTimelineDrop, onCalDrop, xToDateStr,
startBlockMove, startResize,
}
}

View File

@ -1,66 +0,0 @@
import { ref } from 'vue'
import { fetchDevices, fetchPositions } from 'src/api/traccar'
let __gpsStarted = false
let __gpsInterval = null
let __gpsPolling = false
export function useGpsTracking (technicians) {
const traccarDevices = ref([])
const _techsByDevice = {}
function _buildTechDeviceMap () {
Object.keys(_techsByDevice).forEach(k => delete _techsByDevice[k])
technicians.value.forEach(t => {
if (!t.traccarDeviceId) return
const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId)
if (dev) _techsByDevice[dev.id] = t
})
}
function _applyPositions (positions) {
positions.forEach(p => {
const tech = _techsByDevice[p.deviceId]
if (!tech || !p.latitude || !p.longitude) return
const cur = tech.gpsCoords
if (!cur || Math.abs(cur[0] - p.longitude) > 0.00001 || Math.abs(cur[1] - p.latitude) > 0.00001) {
tech.gpsCoords = [p.longitude, p.latitude]
}
tech.gpsSpeed = p.speed || 0
tech.gpsTime = p.fixTime
tech.gpsOnline = true
})
}
async function pollGps () {
if (__gpsPolling) return
__gpsPolling = true
try {
if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices()
_buildTechDeviceMap()
const deviceIds = Object.keys(_techsByDevice).map(Number)
if (!deviceIds.length) return
const positions = await fetchPositions(deviceIds)
_applyPositions(positions)
// Mark techs with no position as offline
Object.values(_techsByDevice).forEach(t => {
if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false
})
} catch { /* poll error */ }
finally { __gpsPolling = false }
}
async function startGpsTracking () {
if (__gpsStarted) return
__gpsStarted = true
await pollGps()
__gpsInterval = setInterval(pollGps, 30000)
}
function stopGpsTracking () {
__gpsStarted = false
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
}
return { traccarDevices, pollGps, startGpsTracking, stopGpsTracking }
}

View File

@ -1,302 +0,0 @@
// ── Uber-style job offer pool ────────────────────────────────────────────────
// Manages the offer lifecycle: create → broadcast → accept/decline → assign
// Supports both push (dispatcher sends to specific techs) and pull (tech picks from pool)
// Pricing: rush/overtime jobs carry displacement fee + hourly rate
// ─────────────────────────────────────────────────────────────────────────────
import { ref, computed } from 'vue'
import { fetchActiveOffers, createOffer, acceptOffer, declineOffer, cancelOffer } from 'src/api/offers'
import { updateJob } from 'src/api/dispatch'
import { sendTestSms } from 'src/api/sms'
import { localDateStr, techDaySchedule, techDayCapacityH, timeToH } from './useHelpers'
// ── Pricing presets ──────────────────────────────────────────────────────────
export const PRICING_PRESETS = {
rush_weekend: {
label: 'Rush fin de semaine',
displacement: 150,
hourlyRate: 125,
currency: 'CAD',
description: 'Déplacement minimum 150$ + 125$/h',
},
rush_evening: {
label: 'Rush soirée',
displacement: 100,
hourlyRate: 100,
currency: 'CAD',
description: 'Déplacement minimum 100$ + 100$/h',
},
rush_holiday: {
label: 'Rush jour férié',
displacement: 200,
hourlyRate: 150,
currency: 'CAD',
description: 'Déplacement minimum 200$ + 150$/h',
},
standard: {
label: 'Tarif régulier',
displacement: 0,
hourlyRate: 0,
currency: 'CAD',
description: 'Inclus dans le plan de service',
},
}
export function useJobOffers (store) {
const offers = ref([])
const loadingOffers = ref(false)
const showOfferPool = ref(false)
// ── Map raw ERPNext doc → local offer object ──────────────────────────────
function _mapOffer (o) {
return {
id: o.name,
name: o.name,
jobName: o.job_name || null, // linked Dispatch Job
subject: o.subject || '',
address: o.address || '',
customer: o.customer || '',
scheduledDate: o.scheduled_date || null,
startTime: o.start_time || null,
duration: parseFloat(o.duration_h) || 1,
priority: o.priority || 'medium',
status: o.status || 'open', // open | pending | accepted | expired | cancelled
// Targeting
offerMode: o.offer_mode || 'broadcast', // broadcast | targeted | pool
targetTechs: o.target_techs ? JSON.parse(o.target_techs) : [], // specific tech IDs
requiredTags: o.required_tags ? JSON.parse(o.required_tags) : [],
// Responses
acceptedBy: o.accepted_by || null,
acceptedAt: o.accepted_at || null,
declinedTechs: o.declined_techs ? JSON.parse(o.declined_techs) : [],
// Pricing
pricingPreset: o.pricing_preset || 'standard',
displacement: parseFloat(o.displacement_fee) || 0,
hourlyRate: parseFloat(o.hourly_rate) || 0,
currency: o.currency || 'CAD',
// Customer checkout
isCustomerRequest: !!o.is_customer_request,
salesOrder: o.sales_order || null,
// Timing
expiresAt: o.expires_at || null,
createdAt: o.creation || null,
// Source
orderSource: o.order_source || 'dispatch', // dispatch | portal | api
}
}
// ── Load active offers ────────────────────────────────────────────────────
async function loadOffers () {
loadingOffers.value = true
try {
const raw = await fetchActiveOffers()
offers.value = raw.map(_mapOffer)
} catch (e) {
console.warn('[offers] load failed:', e.message)
} finally {
loadingOffers.value = false
}
}
// ── Active offers count (for badge) ───────────────────────────────────────
const activeOfferCount = computed(() => offers.value.filter(o => o.status === 'open' || o.status === 'pending').length)
// ── Find matching techs for an offer ──────────────────────────────────────
function matchingTechs (offer) {
if (!store.technicians?.length) return []
return store.technicians.filter(tech => {
// Skip inactive / absent
if (tech.status === 'off' || tech.status === 'inactive') return false
// Skip already declined
if (offer.declinedTechs.some(d => d.techId === tech.id)) return false
// Check required tags/skills
if (offer.requiredTags.length) {
const hasAll = offer.requiredTags.every(reqTag => {
const techTag = tech.tagsWithLevel?.find(t => t.tag === reqTag.tag || t.tag === reqTag)
if (!techTag) return false
if (reqTag.level && techTag.level < reqTag.level) return false
return true
})
if (!hasAll) return false
}
// Check availability on scheduled date
if (offer.scheduledDate) {
const sched = techDaySchedule(tech, offer.scheduledDate)
// For rush/overtime: tech doesn't need to be on schedule
if (!sched && offer.pricingPreset === 'standard') return false
// Check capacity (allow overflow for rush)
if (sched && offer.pricingPreset === 'standard') {
const cap = techDayCapacityH(tech, offer.scheduledDate)
const load = tech.queue
.filter(j => j.scheduledDate === offer.scheduledDate)
.reduce((sum, j) => sum + (parseFloat(j.duration) || 0), 0)
if (load + offer.duration > cap * 1.2) return false // 20% overflow tolerance for standard
}
}
// Targeted mode: only include specified techs
if (offer.offerMode === 'targeted' && offer.targetTechs.length) {
if (!offer.targetTechs.includes(tech.id)) return false
}
return true
})
}
// ── Create and broadcast an offer ─────────────────────────────────────────
async function broadcastOffer (offerData, notifyViaSms = false) {
const pricing = PRICING_PRESETS[offerData.pricingPreset] || PRICING_PRESETS.standard
const payload = {
subject: offerData.subject,
address: offerData.address || '',
customer: offerData.customer || '',
scheduled_date: offerData.scheduledDate || '',
start_time: offerData.startTime || '',
duration_h: offerData.duration || 1,
priority: offerData.priority || 'medium',
status: 'open',
offer_mode: offerData.offerMode || 'broadcast',
target_techs: JSON.stringify(offerData.targetTechs || []),
required_tags: JSON.stringify(offerData.requiredTags || []),
pricing_preset: offerData.pricingPreset || 'standard',
displacement_fee: pricing.displacement,
hourly_rate: pricing.hourlyRate,
currency: pricing.currency,
is_customer_request: offerData.isCustomerRequest ? 1 : 0,
sales_order: offerData.salesOrder || '',
order_source: offerData.orderSource || 'dispatch',
expires_at: offerData.expiresAt || '',
job_name: offerData.jobName || '',
}
const doc = await createOffer(payload)
const mapped = _mapOffer(doc)
offers.value.unshift(mapped)
// Notify matching techs via SMS
if (notifyViaSms) {
const techs = matchingTechs(mapped)
const fmtPrice = pricing.displacement > 0
? `💰 ${pricing.displacement}$ déplacement + ${pricing.hourlyRate}$/h`
: ''
for (const tech of techs) {
if (!tech.phone) continue
const msg = [
`📋 Nouvelle offre de travail:`,
`${mapped.subject}`,
mapped.address ? `📍 ${mapped.address}` : '',
mapped.scheduledDate ? `📅 ${mapped.scheduledDate}${mapped.startTime ? ' à ' + mapped.startTime : ''}` : '',
`${mapped.duration}h`,
fmtPrice,
``,
`Répondez OUI pour accepter ou NON pour décliner.`,
].filter(Boolean).join('\n')
sendTestSms(tech.phone, msg, mapped.customer, {
reference_doctype: 'Dispatch Offer',
reference_name: mapped.id,
}).catch(err => console.warn(`[offer SMS] ${tech.id}:`, err.message))
}
}
return mapped
}
// ── Accept an offer (tech side) ───────────────────────────────────────────
async function handleAccept (offerId, techId) {
const offer = offers.value.find(o => o.id === offerId)
if (!offer) throw new Error('Offer not found')
// Update offer status
await acceptOffer(offerId, techId)
offer.status = 'accepted'
offer.acceptedBy = techId
// Create or assign the dispatch job
if (offer.jobName) {
// Existing job — assign to accepting tech
await store.assignJobToTech(offer.jobName, techId,
store.technicians.find(t => t.id === techId)?.queue.length || 0,
offer.scheduledDate)
} else {
// Create new job from offer
const job = await store.createJob({
subject: offer.subject,
address: offer.address,
duration_h: offer.duration,
priority: offer.priority,
scheduled_date: offer.scheduledDate,
start_time: offer.startTime,
assigned_tech: techId,
customer: offer.customer,
sales_order: offer.salesOrder,
order_source: offer.orderSource,
})
// Link job back to offer
if (job?.name) {
await import('src/api/offers').then(m => m.updateOffer(offerId, { job_name: job.name }))
offer.jobName = job.name
}
}
return offer
}
// ── Decline an offer (tech side) ──────────────────────────────────────────
async function handleDecline (offerId, techId, reason = '') {
const offer = offers.value.find(o => o.id === offerId)
if (!offer) return
await declineOffer(offerId, techId, reason)
offer.declinedTechs.push({ techId, reason, at: new Date().toISOString() })
// Check if all targeted techs declined → auto-expire
if (offer.offerMode === 'targeted' && offer.targetTechs.length) {
const allDeclined = offer.targetTechs.every(tid =>
offer.declinedTechs.some(d => d.techId === tid)
)
if (allDeclined) {
await cancelOffer(offerId)
offer.status = 'expired'
}
}
}
// ── Cancel an offer (dispatcher side) ─────────────────────────────────────
async function handleCancel (offerId) {
const offer = offers.value.find(o => o.id === offerId)
if (!offer) return
await cancelOffer(offerId)
offer.status = 'cancelled'
}
// ── Estimate cost for a rush job ──────────────────────────────────────────
function estimateCost (preset, durationH) {
const p = PRICING_PRESETS[preset] || PRICING_PRESETS.standard
return {
displacement: p.displacement,
labour: Math.ceil(durationH * p.hourlyRate * 100) / 100,
total: p.displacement + Math.ceil(durationH * p.hourlyRate * 100) / 100,
currency: p.currency,
description: p.description,
}
}
// ── Create offer from existing unassigned job ─────────────────────────────
async function offerExistingJob (job, opts = {}) {
return broadcastOffer({
jobName: job.name || job.id,
subject: job.subject,
address: job.address,
customer: job.customer,
scheduledDate: job.scheduledDate,
startTime: job.startTime,
duration: job.duration,
priority: job.priority,
pricingPreset: opts.pricingPreset || 'standard',
offerMode: opts.offerMode || 'broadcast',
targetTechs: opts.targetTechs || [],
requiredTags: opts.requiredTags || [],
orderSource: 'dispatch',
expiresAt: opts.expiresAt || '',
}, opts.sms !== false)
}
return {
offers, loadingOffers, showOfferPool, activeOfferCount,
loadOffers, broadcastOffer, handleAccept, handleDecline, handleCancel,
matchingTechs, estimateCost, offerExistingJob,
}
}

View File

@ -1,130 +0,0 @@
import { ref, computed, watch } from 'vue'
export function useResourceFilter (store, opts = {}) {
const selectedResIds = ref(JSON.parse(localStorage.getItem('sbv2-resIds') || '[]'))
const filterStatus = ref(localStorage.getItem('sbv2-filterStatus') || '')
const filterGroup = ref(localStorage.getItem('sbv2-filterGroup') || '')
const filterTags = ref(JSON.parse(localStorage.getItem('sbv2-filterTags') || '[]'))
// Default 'human' = show only techs. Materials are secondary and
// accessed via the resource-type dropdown when needed. Once the user
// explicitly picks something else (incl. ''), it's persisted.
const filterResourceType = ref(localStorage.getItem('sbv2-filterResType') ?? 'human') // '' | 'human' | 'material'
const searchQuery = ref('')
const techSort = ref(localStorage.getItem('sbv2-techSort') || 'default')
const manualOrder = ref(JSON.parse(localStorage.getItem('sbv2-techOrder') || '[]'))
const resSelectorOpen = ref(false)
const tempSelectedIds = ref([])
const dragReorderTech = ref(null)
const hideAbsent = ref(false) // Quick toggle: hide techs absent on current day
watch(selectedResIds, v => localStorage.setItem('sbv2-resIds', JSON.stringify(v)), { deep: true })
watch(filterStatus, v => localStorage.setItem('sbv2-filterStatus', v))
watch(filterGroup, v => localStorage.setItem('sbv2-filterGroup', v))
watch(filterResourceType, v => localStorage.setItem('sbv2-filterResType', v))
watch(techSort, v => localStorage.setItem('sbv2-techSort', v))
// Unique groups extracted from technicians
const availableGroups = computed(() => {
const groups = new Set()
for (const t of store.technicians) {
if (t.group) groups.add(t.group)
}
return [...groups].sort()
})
// Resource categories for material resources
const availableCategories = computed(() => {
const cats = new Set()
for (const t of store.technicians) {
if (t.resourceCategory) cats.add(t.resourceCategory)
}
return [...cats].sort()
})
// Count by type
const humanCount = computed(() => store.technicians.filter(t => t.resourceType !== 'material').length)
const materialCount = computed(() => store.technicians.filter(t => t.resourceType === 'material').length)
const showInactive = ref(false)
const filteredResources = computed(() => {
let list = store.technicians
// Hide permanently inactive techs unless explicitly showing them or filtering for them
// Temporarily absent (off) techs stay visible so absence blocks render on the timeline
if (!showInactive.value && filterStatus.value !== 'inactive') list = list.filter(t => t.status !== 'inactive')
if (filterResourceType.value) list = list.filter(t => (t.resourceType || 'human') === filterResourceType.value)
if (searchQuery.value) { const q = searchQuery.value.toLowerCase(); list = list.filter(t => t.fullName.toLowerCase().includes(q)) }
if (filterStatus.value) list = list.filter(t => (t.status||'available') === filterStatus.value)
if (filterGroup.value) list = list.filter(t => t.group === filterGroup.value)
if (selectedResIds.value.length) list = list.filter(t => selectedResIds.value.includes(t.id))
if (filterTags.value.length) list = list.filter(t => filterTags.value.every(ft => (t.tags||[]).includes(ft)))
// Quick toggle: hide techs absent on the current viewed day
if (hideAbsent.value && opts.isAbsentOnDay) list = list.filter(t => !opts.isAbsentOnDay(t))
// Sort: humans first, then material; within each, apply chosen sort
list = [...list].sort((a, b) => {
const aType = a.resourceType === 'material' ? 1 : 0
const bType = b.resourceType === 'material' ? 1 : 0
if (aType !== bType) return aType - bType
if (techSort.value === 'alpha') return a.fullName.split(' ').pop().toLowerCase().localeCompare(b.fullName.split(' ').pop().toLowerCase())
if (techSort.value === 'load' && opts.getLoadH) return (opts.getLoadH(a) || 0) - (opts.getLoadH(b) || 0)
if (techSort.value === 'manual' && manualOrder.value.length) {
const order = manualOrder.value
return (order.indexOf(a.id) === -1 ? 999 : order.indexOf(a.id)) - (order.indexOf(b.id) === -1 ? 999 : order.indexOf(b.id))
}
return 0
})
return list
})
// Resources grouped by tech_group for visual grouping
const groupedResources = computed(() => {
const groups = new Map()
for (const t of filteredResources.value) {
const g = t.group || ''
if (!groups.has(g)) groups.set(g, [])
groups.get(g).push(t)
}
// Sort: named groups first (alphabetically), then ungrouped last
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 || 'Non assigné', techs }))
})
function onTechReorderStart (e, tech) {
dragReorderTech.value = tech
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', 'reorder')
}
function onTechReorderDrop (e, targetTech) {
e.preventDefault()
if (!dragReorderTech.value || dragReorderTech.value.id === targetTech.id) { dragReorderTech.value = null; return }
techSort.value = 'manual'
const ids = filteredResources.value.map(t => t.id)
const fromIdx = ids.indexOf(dragReorderTech.value.id), toIdx = ids.indexOf(targetTech.id)
ids.splice(fromIdx, 1); ids.splice(toIdx, 0, dragReorderTech.value.id)
manualOrder.value = ids; localStorage.setItem('sbv2-techOrder', JSON.stringify(ids))
dragReorderTech.value = null
}
function openResSelector () { tempSelectedIds.value = [...selectedResIds.value]; resSelectorOpen.value = true }
function applyResSelector () { selectedResIds.value = [...tempSelectedIds.value]; resSelectorOpen.value = false }
function toggleTempRes (id) {
const idx = tempSelectedIds.value.indexOf(id)
if (idx >= 0) tempSelectedIds.value.splice(idx, 1); else tempSelectedIds.value.push(id)
}
function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; filterGroup.value = ''; filterResourceType.value = ''; searchQuery.value = ''; filterTags.value = []; showInactive.value = false; hideAbsent.value = false; localStorage.removeItem('sbv2-filterTags'); localStorage.removeItem('sbv2-filterGroup'); localStorage.removeItem('sbv2-filterResType') }
// Count of inactive techs (for UI indicator)
const inactiveCount = computed(() => store.technicians.filter(t => !t.active).length)
return {
selectedResIds, filterStatus, filterGroup, filterResourceType, filterTags, searchQuery, techSort, manualOrder,
showInactive, hideAbsent, inactiveCount, humanCount, materialCount, availableCategories,
filteredResources, groupedResources, availableGroups, resSelectorOpen, tempSelectedIds, dragReorderTech,
openResSelector, applyResSelector, toggleTempRes, clearFilters,
onTechReorderStart, onTechReorderDrop,
}
}

View File

@ -1,389 +0,0 @@
// ── 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,
}
}

View File

@ -1,172 +0,0 @@
// ── Selection composable: lasso, multi-select, hover linking, batch ops ───────
import { ref, computed } from 'vue'
import { localDateStr } from './useHelpers'
export function useSelection (deps) {
const { store, periodStart, smartAssign, invalidateRoutes, fullUnassign } = deps
const hoveredJobId = ref(null)
const selectedJob = ref(null) // { job, techId, isAssist?, assistTechId? }
const multiSelect = ref([]) // [{ job, techId, isAssist?, assistTechId? }]
// ── Select / toggle ─────────────────────────────────────────────────────────
function selectJob (job, techId, isAssist = false, assistTechId = null, event = null, rightPanel = null) {
const entry = { job, techId, isAssist, assistTechId }
const isMulti = event && (event.ctrlKey || event.metaKey)
if (isMulti) {
const idx = multiSelect.value.findIndex(s => s.job.id === job.id && s.isAssist === isAssist)
if (idx >= 0) multiSelect.value.splice(idx, 1)
else multiSelect.value.push(entry)
selectedJob.value = entry
} else {
multiSelect.value = []
const same = selectedJob.value?.job?.id === job.id && selectedJob.value?.isAssist === isAssist && selectedJob.value?.assistTechId === assistTechId
selectedJob.value = same ? null : entry
if (!same && rightPanel !== undefined) {
const tech = store.technicians.find(t => t.id === (techId || job.assignedTech))
if (rightPanel !== null && typeof rightPanel === 'object' && 'value' in rightPanel) {
rightPanel.value = { mode: 'details', data: { job, tech: tech || null } }
}
} else if (rightPanel !== null && typeof rightPanel === 'object' && 'value' in rightPanel) {
rightPanel.value = null
}
}
}
function isJobMultiSelected (jobId, isAssist = false) {
return multiSelect.value.some(s => s.job.id === jobId && s.isAssist === isAssist)
}
// ── Batch ops (grouped undo) ──────────────────────────────────────────────────
function batchUnassign (pushUndo) {
if (!multiSelect.value.length) return
// Snapshot all jobs before unassign — single undo entry
const assignments = multiSelect.value.filter(s => !s.isAssist).map(s => ({
jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder,
scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])]
}))
const prevQueues = {}
store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] })
multiSelect.value.forEach(s => {
if (s.isAssist && s.assistTechId) store.removeAssistant(s.job.id, s.assistTechId)
else store.fullUnassign(s.job.id)
})
if (pushUndo && assignments.length) {
pushUndo({ type: 'batchAssign', assignments, prevQueues })
}
multiSelect.value = []; selectedJob.value = null
invalidateRoutes()
}
function batchMoveTo (techId, dayStr, pushUndo) {
if (!multiSelect.value.length) return
const day = dayStr || localDateStr(periodStart.value)
const jobs = multiSelect.value.filter(s => !s.isAssist)
// Snapshot for grouped undo
const assignments = jobs.map(s => ({
jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder,
scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])]
}))
const prevQueues = {}
store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] })
jobs.forEach(s => smartAssign(s.job, techId, day))
if (pushUndo && assignments.length) {
pushUndo({ type: 'batchAssign', assignments, prevQueues })
}
multiSelect.value = []; selectedJob.value = null
invalidateRoutes()
}
// ── Lasso ─────────────────────────────────────────────────────────────────────
const lasso = ref(null)
const boardScroll = ref(null)
const lassoStyle = computed(() => {
if (!lasso.value) return {}
const l = lasso.value
return {
left: Math.min(l.x1, l.x2) + 'px', top: Math.min(l.y1, l.y2) + 'px',
width: Math.abs(l.x2 - l.x1) + 'px', height: Math.abs(l.y2 - l.y1) + 'px',
}
})
function startLasso (e) {
if (e.target.closest('.sb-block, .sb-chip, .sb-res-cell, .sb-travel-trail, button, input, select, a')) return
if (e.button !== 0) return
e.preventDefault()
if (!e.ctrlKey && !e.metaKey) {
if (selectedJob.value || multiSelect.value.length) {
selectedJob.value = null; multiSelect.value = []
}
}
const rect = boardScroll.value.getBoundingClientRect()
const x = e.clientX - rect.left + boardScroll.value.scrollLeft
const y = e.clientY - rect.top + boardScroll.value.scrollTop
lasso.value = { x1: x, y1: y, x2: x, y2: y }
}
function moveLasso (e) {
if (!lasso.value) return
e.preventDefault()
const rect = boardScroll.value.getBoundingClientRect()
lasso.value.x2 = e.clientX - rect.left + boardScroll.value.scrollLeft
lasso.value.y2 = e.clientY - rect.top + boardScroll.value.scrollTop
}
function endLasso () {
if (!lasso.value) return
const l = lasso.value
const w = Math.abs(l.x2 - l.x1), h = Math.abs(l.y2 - l.y1)
if (w > 10 && h > 10) {
const boardRect = boardScroll.value.getBoundingClientRect()
const lassoLeft = Math.min(l.x1, l.x2) - boardScroll.value.scrollLeft + boardRect.left
const lassoTop = Math.min(l.y1, l.y2) - boardScroll.value.scrollTop + boardRect.top
const lassoRight = lassoLeft + w, lassoBottom = lassoTop + h
const blocks = boardScroll.value.querySelectorAll('.sb-block[data-job-id], .sb-chip')
const selected = []
blocks.forEach(el => {
const r = el.getBoundingClientRect()
if (r.right > lassoLeft && r.left < lassoRight && r.bottom > lassoTop && r.top < lassoBottom) {
const jobId = el.dataset?.jobId
if (jobId) {
const job = store.jobs.find(j => j.id === jobId)
if (job) selected.push({ job, techId: job.assignedTech, isAssist: false, assistTechId: null })
}
}
})
if (selected.length) {
multiSelect.value = selected
if (selected.length === 1) selectedJob.value = selected[0]
}
}
lasso.value = null
}
// ── Hover linking helpers ─────────────────────────────────────────────────────
function techHasLinkedJob (tech) {
const hId = hoveredJobId.value, sId = selectedJob.value?.job?.id
if (hId && (tech.assistJobs || []).some(j => j.id === hId)) return true
if (hId && tech.queue.some(j => j.id === hId)) return true
if (sId && !selectedJob.value?.isAssist && (tech.assistJobs || []).some(j => j.id === sId)) return true
if (sId && selectedJob.value?.isAssist && tech.queue.some(j => j.id === sId)) return true
return false
}
function techIsHovered (tech) {
const hId = hoveredJobId.value
if (!hId) return false
const job = tech.queue.find(j => j.id === hId)
return job && job.assistants?.length > 0
}
return {
hoveredJobId, selectedJob, multiSelect,
selectJob, isJobMultiSelected, batchUnassign, batchMoveTo,
lasso, boardScroll, lassoStyle, startLasso, moveLasso, endLasso,
techHasLinkedJob, techIsHovered,
}
}

View File

@ -1,78 +0,0 @@
// ── Undo stack composable ────────────────────────────────────────────────────
import { ref, nextTick } from 'vue'
import { updateJob } from 'src/api/dispatch'
import { serializeAssistants } from './useHelpers'
export function useUndo (store, invalidateRoutes) {
const undoStack = ref([])
function pushUndo (action) {
undoStack.value.push(action)
if (undoStack.value.length > 30) undoStack.value.shift()
}
// Restore a single job to its previous state (unassign from current tech, re-assign if it had one)
function _restoreJob (prev) {
const job = store.jobs.find(j => j.id === prev.jobId)
if (!job) return
// Remove from all tech queues first
store.technicians.forEach(t => { t.queue = t.queue.filter(q => q.id !== prev.jobId) })
if (prev.techId) {
// Was assigned before — re-assign
store.assignJobToTech(prev.jobId, prev.techId, prev.routeOrder, prev.scheduledDate)
} else {
// Was unassigned before — just mark as open
job.assignedTech = null
job.status = 'open'
job.scheduledDate = prev.scheduledDate || null
updateJob(job.name || job.id, { assigned_tech: null, status: 'open', scheduled_date: prev.scheduledDate || '' }).catch(() => {})
}
if (prev.assistants?.length) {
job.assistants = prev.assistants
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
}
}
function performUndo () {
const action = undoStack.value.pop()
if (!action) return
if (action.type === 'removeAssistant') {
store.addAssistant(action.jobId, action.techId)
nextTick(() => {
const job = store.jobs.find(j => j.id === action.jobId)
const a = job?.assistants.find(x => x.techId === action.techId)
if (a) { a.duration = action.duration; a.note = action.note }
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
})
} else if (action.type === 'optimizeRoute') {
const tech = store.technicians.find(t => t.id === action.techId)
if (tech) {
tech.queue = action.prevQueue
action.prevQueue.forEach((j, i) => { j.routeOrder = i })
}
} else if (action.type === 'autoDistribute') {
action.assignments.forEach(a => _restoreJob(a))
if (action.prevQueues) {
store.technicians.forEach(t => {
if (action.prevQueues[t.id]) t.queue = action.prevQueues[t.id]
})
}
} else if (action.type === 'batchAssign') {
// Undo a multi-select drag — restore each job to previous state
action.assignments.forEach(a => _restoreJob(a))
} else if (action.type === 'unassignJob') {
_restoreJob(action)
}
// Rebuild assistJobs on all techs
store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) })
invalidateRoutes()
}
return { undoStack, pushUndo, performUndo }
}

View File

@ -1,39 +0,0 @@
/**
* API d'assignation UNIFIÉE — chemin d'écriture UNIQUE (via targo-hub).
* -----------------------------------------------------------------------------
* CONVERGENCE Dispatch Planification : l'assignation jobtech
* (`assigned_tech` / `scheduled_date` / `route_order` / `start_time` / `status`
* + l'ÉQUIPE) ne doit JAMAIS être écrite en direct dans ERPNext (PUT
* api/dispatch.js). Tout passe ici le hub (lib/roster.js) qui valide, pose le
* first-fit `start_time`, garde la durée, le booking_status, et après 1b
* préserve les assistants. Un seul écrivain plus de conflit « dernier qui
* écrit gagne » entre les deux UIs.
*
* NE PAS importer api/dispatch.js#updateJob pour ces champs. Utiliser ceci.
*/
import { HUB_URL as HUB } from 'src/config/hub'
async function jpost (path, body) {
const r = await fetch(HUB + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
})
if (!r.ok) throw new Error('Assignment API ' + r.status + ' ' + path)
return r.json()
}
// Assigner un job à un tech sur une date (le hub pose first-fit start_time + garde durée + booking_status).
export const assign = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
// Réordonner / re-prioriser les jobs d'un tech×jour — updates = [{ job, route_order, priority? }]
export const reorder = (updates) => jpost('/roster/reorder-jobs', { updates })
// Retirer un job du tech (retour au pool non assigné).
export const unassign = (job) => jpost('/roster/unassign-job', { job })
// Équipe (lead + assistants). assistants = [{ tech_id, tech_name, duration_h, note, pinned }].
// Le LEAD reste `assigned_tech` (inchangé) ; on ne fait qu'éditer la table-enfant `assistants`.
// Route hub /roster/job/team ajoutée en 1b (qui persiste la table-enfant + recalcule l'occupation
// des assistants pinnés). Avant 1b, cet appel échoue proprement (404) — aucune vue ne l'appelle encore.
export const setTeam = (job, assistants) => jpost('/roster/job/team', { job, assistants })

View File

@ -1,62 +0,0 @@
/**
* useAssignment composable d'assignation UNIFIÉE (le SEUL point d'écriture
* jobtech, partagé par le board Dispatch ET la grille Planification).
* -----------------------------------------------------------------------------
* Les vues appellent assign/reorder/unassign/setTeam JAMAIS updateJob() direct.
* Le composable est agnostique du store : la vue fournit ses hooks optimistes.
*
* const A = useAssignment({
* onError: (e) => $q.notify({ type:'negative', message:String(e) }),
* onLocal: (fn) => fn(), // appliquer une MAJ locale optimiste (selon le store)
* })
* await A.assign(jobName, techId, '2026-06-10')
*
* `assigned_tech` = clé d'identité tech NORMALISÉE (voir useResources.techKey) :
* on passe l'id tech ; le hub résout le docname Dispatch Technician.
*/
import * as api from '../api/assignment'
// Sérialise une liste d'assistants (modèle front {techId,...} OU déjà {tech_id,...}) → format hub/ERPNext.
export function serializeAssistants (assistants) {
return (assistants || []).map(a => ({
tech_id: a.techId ?? a.tech_id,
tech_name: a.techName ?? a.tech_name ?? '',
duration_h: a.duration ?? a.duration_h ?? null,
note: a.note || '',
pinned: a.pinned ? 1 : 0,
}))
}
const aid = (a) => a && (a.techId ?? a.tech_id)
export function useAssignment (opts = {}) {
const onErr = opts.onError || ((e) => console.error('[assignment]', e))
const local = opts.onLocal || ((fn) => { try { fn && fn() } catch (e) { /* noop */ } })
async function run (fn, optimistic) {
if (optimistic) local(optimistic) // MAJ optimiste immédiate (UX réactive)
try { return await fn() } catch (e) { onErr(e); throw e }
}
// Assigner job→tech sur une date (option optimistic = closure de MAJ locale).
const assign = (job, tech, date, optimistic) => run(() => api.assign(job, tech, date), optimistic)
// Réordonner : updates = [{ job, route_order, priority? }].
const reorder = (updates, optimistic) => run(() => api.reorder(updates), optimistic)
// Désassigner (retour au pool).
const unassign = (job, optimistic) => run(() => api.unassign(job), optimistic)
// Remplacer l'équipe complète d'un job.
const setTeam = (job, assistants, optimistic) => run(() => api.setTeam(job, serializeAssistants(assistants)), optimistic)
// Ajouter un assistant (dédupe par tech) — `current` = liste actuelle d'assistants du job.
function addAssistant (job, current, assistant, optimistic) {
const next = [...(current || []).filter(a => aid(a) !== aid(assistant)), assistant]
return setTeam(job, next, optimistic)
}
// Retirer un assistant par id tech.
function removeAssistant (job, current, techId, optimistic) {
const next = (current || []).filter(a => aid(a) !== techId)
return setTeam(job, next, optimistic)
}
return { assign, reorder, unassign, setTeam, addAssistant, removeAssistant }
}

View File

@ -1,93 +0,0 @@
/**
* useResources « rail » de ressources UNIFIÉE (techs + sous-groupes + recherche
* + filtres + tri + masqués + scoring), partagée par TOUTES les vues.
* -----------------------------------------------------------------------------
* CONVERGENCE : aujourd'hui la liste de techs / sous-groupes / filtres est
* RECRÉÉE dans chaque page (Dispatch: useResourceFilter ; Planification:
* visibleTechs+groupFilter+search+hiddenTechs+priorityScores). On la définit ICI
* UNE fois ; chaque vue la consomme. Agnostique de la source : on lui passe un
* getter/ref du tableau de techs (modèle « union »).
*
* Modèle Tech union (champs lus ici, tous optionnels sauf id) :
* { id, name, fullName?, group?, status?, resourceType?, tags?[] }
*
* IDENTITÉ : `techKey(t)` normalise sur UNE clé (id) corrige l'ambiguïté
* docname Dispatch Technician vs technician_id repérée à la cartographie.
*/
import { ref, computed, watch, unref } from 'vue'
const lsGet = (k, d) => { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v) } catch { return d } }
const lsSet = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)) } catch { /* quota */ } }
export const techKey = (t) => (t && (t.id ?? t.technician_id ?? t.name)) || ''
const nameOf = (t) => t.fullName || t.full_name || t.name || ''
const lastName = (t) => nameOf(t).split(' ').pop().toLowerCase()
/**
* @param techsSource ref|getter renvoyant le tableau de techs
* @param opts.lsPrefix préfixe localStorage (persistance par contexte), défaut 'wf-'
* @param opts.defaultType '' | 'human' | 'material' filtre type de ressource initial
* @param opts.showHidden ref<boolean> si true, on affiche les masqués (grisés) au lieu de les cacher
* @param opts.score (t)=>number score optionnel (Planification : compétence+vitesse+coût) tri décroissant
* @param opts.loadH (t)=>number charge horaire (pour le tri 'load')
*/
export function useResources (techsSource, opts = {}) {
const P = opts.lsPrefix || 'wf-'
const techs = () => (unref(typeof techsSource === 'function' ? techsSource() : techsSource)) || []
const search = ref('')
const groupFilter = ref(lsGet(P + 'group', '') || '')
const resourceType = ref(opts.defaultType ?? '') // '' | 'human' | 'material'
const hidden = ref(lsGet(P + 'hidden', []) || []) // ids masqués
const sort = ref(lsGet(P + 'sort', 'default') || 'default') // 'default'|'alpha'|'load'
const showHidden = opts.showHidden || ref(false)
watch(groupFilter, v => lsSet(P + 'group', v))
watch(hidden, v => lsSet(P + 'hidden', v), { deep: true })
watch(sort, v => lsSet(P + 'sort', v))
// Sous-groupes (tech_group) présents, triés.
const groups = computed(() => { const s = new Set(); for (const t of techs()) if (t.group) s.add(t.group); return [...s].sort() })
const isHidden = (t) => hidden.value.includes(techKey(t))
// Liste filtrée + triée (humains avant matériel ; puis score|alpha|load|défaut).
const filtered = computed(() => {
let list = techs()
if (resourceType.value) list = list.filter(t => (t.resourceType || 'human') === resourceType.value)
if (search.value) { const q = search.value.toLowerCase(); list = list.filter(t => nameOf(t).toLowerCase().includes(q)) }
if (groupFilter.value) list = list.filter(t => t.group === groupFilter.value)
if (!showHidden.value) list = list.filter(t => !isHidden(t))
list = [...list].sort((a, b) => {
const at = a.resourceType === 'material' ? 1 : 0, bt = b.resourceType === 'material' ? 1 : 0
if (at !== bt) return at - bt
if (opts.score) return (opts.score(b) || 0) - (opts.score(a) || 0)
if (sort.value === 'alpha') return lastName(a).localeCompare(lastName(b))
if (sort.value === 'load' && opts.loadH) return (opts.loadH(a) || 0) - (opts.loadH(b) || 0)
return 0
})
return list
})
// Regroupé par sous-groupe (groupes nommés d'abord, « Non assigné » à la fin).
const grouped = computed(() => {
const m = new Map()
for (const t of filtered.value) { const g = t.group || ''; if (!m.has(g)) m.set(g, []); m.get(g).push(t) }
return [...m.entries()]
.sort((a, b) => (!a[0] && b[0]) ? 1 : (a[0] && !b[0]) ? -1 : a[0].localeCompare(b[0]))
.map(([name, list]) => ({ name, label: name || 'Non assigné', techs: list }))
})
const toggleHidden = (id) => { hidden.value = hidden.value.includes(id) ? hidden.value.filter(x => x !== id) : [...hidden.value, id] }
const hiddenCount = computed(() => hidden.value.length)
const clearFilters = () => { search.value = ''; groupFilter.value = ''; resourceType.value = opts.defaultType ?? '' }
return {
// état
search, groupFilter, resourceType, hidden, sort, showHidden,
// dérivés
groups, filtered, grouped, hiddenCount,
// actions
toggleHidden, clearFilters, isHidden, techKey,
}
}

View File

@ -1,205 +0,0 @@
<script setup>
import { ref, inject } from 'vue'
import { fmtDur, shortAddr, prioLabel, prioClass, prioColor, dayLoadColor, ICON } from 'src/composables/useHelpers'
const props = defineProps({
open: Boolean,
height: Number,
groups: Array,
unscheduledCount: Number,
selected: Object, // Set
dropActive: Boolean,
sort: { type: String, default: 'date' }, // tri du pool : date | city | priority
})
const emit = defineEmits([
'update:open', 'update:height', 'resize-start', 'update:sort',
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
'auto-distribute', 'open-criteria',
'row-click', 'row-dblclick', 'row-dragstart',
'drop-unassign', 'lasso-select', 'deselect-all',
])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const btColW = inject('btColW')
const startColResize = inject('startColResize')
// Aujourd'hui (fuseau Québec) un groupe de date PASSÉE = tickets « en retard » (à traiter/fermer).
const todayISO = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
// Lasso selection
const btLasso = ref(null)
const btScrollRef = ref(null)
let btLassoMoved = false
function btLassoStart (e) {
if (e.target.closest('button, input, .sb-bt-checkbox, a, .sb-col-resize, .sb-bottom-hdr, .sb-bottom-resize')) return
if (e.button !== 0) return
const scroll = btScrollRef.value
if (!scroll) return
// On a job row don't start lasso, let drag handle it
const row = e.target.closest('.sb-bottom-row')
if (row) return
e.preventDefault()
btLassoMoved = false
const rect = scroll.getBoundingClientRect()
const x = e.clientX - rect.left + scroll.scrollLeft
const y = e.clientY - rect.top + scroll.scrollTop
btLasso.value = { x1: x, y1: y, x2: x, y2: y }
document.addEventListener('mousemove', btLassoMove)
document.addEventListener('mouseup', btLassoEnd)
}
function btLassoMove (e) {
if (!btLasso.value) return
e.preventDefault()
btLassoMoved = true
const scroll = btScrollRef.value
const rect = scroll.getBoundingClientRect()
btLasso.value.x2 = e.clientX - rect.left + scroll.scrollLeft
btLasso.value.y2 = e.clientY - rect.top + scroll.scrollTop
// Live selection as lasso moves
const l = btLasso.value
const h = Math.abs(l.y2 - l.y1)
if (h > 8) {
const scrollRect = scroll.getBoundingClientRect()
const lassoTop = Math.min(l.y1, l.y2) - scroll.scrollTop + scrollRect.top
const lassoBottom = lassoTop + h
const rows = scroll.querySelectorAll('.sb-bottom-row')
const ids = []
rows.forEach(row => {
const r = row.getBoundingClientRect()
if (r.bottom > lassoTop && r.top < lassoBottom) {
const jobId = row.dataset?.jobId
if (jobId) ids.push(jobId)
}
})
if (ids.length) emit('lasso-select', ids)
}
}
function btLassoEnd () {
document.removeEventListener('mousemove', btLassoMove)
document.removeEventListener('mouseup', btLassoEnd)
if (!btLasso.value) return
// If no movement = click on empty space = clear selection
if (!btLassoMoved) {
emit('deselect-all')
}
btLasso.value = null
}
</script>
<template>
<div v-if="open" class="sb-bottom-panel" :style="'height:'+height+'px'">
<div class="sb-bottom-resize" @mousedown.prevent="emit('resize-start', $event)"></div>
<div class="sb-bottom-hdr">
<span class="sb-bottom-title">
Jobs non assignées
<span class="sbf-count">{{ unscheduledCount }}</span>
</span>
<button v-if="unscheduledCount" class="sbf-auto-btn" @click="emit('auto-distribute')" title="Répartir automatiquement"> Répartir auto</button>
<button class="sbf-auto-btn" style="border-color:rgba(255,255,255,0.12)" @click="emit('open-criteria')" title="Critères de dispatch"> Critères</button>
<label style="display:inline-flex;align-items:center;gap:4px;font-size:0.72rem;opacity:.85" title="Trier le pool">
<select :value="sort" @change="emit('update:sort', $event.target.value)" style="background:rgba(255,255,255,0.06);color:inherit;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:1px 4px;font-size:0.72rem">
<option value="date">Date</option><option value="city">Ville</option><option value="priority">Priorité</option>
</select>
</label>
<!-- Batch assign bar -->
<template v-if="selected.size">
<span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
<span class="sb-bottom-sel-lbl"></span>
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech"
:style="'border-color:'+TECH_COLORS[t.colorIdx]" :title="t.fullName"
@click="emit('batch-assign', t.id)">
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</button>
<button class="sb-bottom-sel-clear" @click="emit('clear-select')"></button>
</template>
<div style="flex:1"></div>
<button v-if="unscheduledCount" class="sb-bottom-sel-all" @click="emit('select-all')" title="Tout sélectionner"> Tout</button>
<button class="sb-bottom-close" @click="emit('update:open', false)"></button>
</div>
<div class="sb-bottom-body"
:class="{ 'sbf-drop-active': dropActive }"
@dragover.prevent="$emit('drop-unassign', $event, 'over')"
@dragleave="$emit('drop-unassign', $event, 'leave')"
@drop="$emit('drop-unassign', $event, 'drop')">
<div v-if="dropActive" class="sbf-drop-hint" style="margin:4px"> Désaffecter ici</div>
<table class="sb-bottom-table">
<thead>
<tr>
<th class="sb-bt-chk" style="width:28px"></th>
<th class="sb-bt-prio" style="width:12px"></th>
<th class="sb-bt-name" :style="'width:'+btColW('name',200)"><span>Nom</span><div class="sb-col-resize" @mousedown="startColResize($event,'name')"></div></th>
<th class="sb-bt-addr" :style="'width:'+btColW('addr',180)"><span>Adresse</span><div class="sb-col-resize" @mousedown="startColResize($event,'addr')"></div></th>
<th class="sb-bt-dur" :style="'width:'+btColW('dur',130)"><span>Durée</span><div class="sb-col-resize" @mousedown="startColResize($event,'dur')"></div></th>
<th class="sb-bt-prio-lbl" style="width:70px">Priorité</th>
<th class="sb-bt-tags">Skills / Tags</th>
</tr>
</thead>
</table>
<div class="sb-bottom-scroll" ref="btScrollRef" @mousedown="btLassoStart" style="position:relative">
<template v-for="group in groups" :key="group.key || group.date || 'nodate'">
<div class="sb-bottom-date-sep">
<span class="sb-bottom-date-label">{{ group.label }}</span>
<span class="sb-bottom-date-count">{{ group.jobs.length }}</span>
<span v-if="group.date && group.date < todayISO" :style="{ marginLeft:'8px', color:'#e53935', fontWeight:700, fontSize:'10px', letterSpacing:'.3px' }" title="Date dépassée — à replanifier ou fermer"> EN RETARD</span>
</div>
<table class="sb-bottom-table">
<tbody>
<tr v-for="job in group.jobs" :key="job.id"
class="sb-bottom-row" :class="{ 'sb-bottom-row-sel': selected.has(job.id) }"
:data-job-id="job.id"
draggable="true"
@dragstart="emit('row-dragstart', $event, job, selected.has(job.id) && selected.size > 1)"
@click="emit('row-click', job, $event)"
@dblclick.stop="emit('row-dblclick', job)">
<td class="sb-bt-chk" style="width:28px" @click.stop="emit('toggle-select', job.id, $event)">
<span class="sb-bt-checkbox" :class="{ checked: selected.has(job.id) }"></span>
</td>
<td class="sb-bt-prio" style="width:12px">
<span class="sb-bt-prio-dot" :style="'background:'+prioColor(job.priority)" :title="prioLabel(job.priority)"></span>
</td>
<td class="sb-bt-name" :style="'width:'+btColW('name',200)">
<span :style="{ display:'inline-block', width:'9px', height:'9px', borderRadius:'2px', marginRight:'6px', verticalAlign:'middle', flex:'0 0 auto', background: jobColor(job) }" :title="job.legacyDept || job.jobType || ''"></span>
<span class="sb-bt-name-text">{{ job.subject }}</span>
</td>
<td class="sb-bt-addr" :style="'width:'+btColW('addr',180)">{{ shortAddr(job.address) || '—' }}</td>
<td class="sb-bt-dur" :style="'width:'+btColW('dur',130)">
<div class="sb-bt-dur-wrap">
<div class="sb-bt-dur-bar">
<div class="sb-bt-dur-fill" :style="{ width: Math.min(100,(parseFloat(job.duration)||0)/8*100)+'%', background: dayLoadColor((parseFloat(job.duration)||0)/8) }"></div>
</div>
<span class="sb-bt-dur-lbl">{{ fmtDur(job.duration) }}</span>
</div>
</td>
<td class="sb-bt-prio-lbl" style="width:70px">
<span :class="prioClass(job.priority)" class="sb-bt-prio-tag">{{ prioLabel(job.priority) }}</span>
</td>
<td class="sb-bt-tags">
<span v-for="t in (job.tags||[])" :key="t" class="sb-bt-skill-chip">{{ t }}</span>
<span v-if="!(job.tags||[]).length" class="sb-bt-no-tag"></span>
</td>
</tr>
</tbody>
</table>
</template>
<div v-if="!unscheduledCount" class="sbf-empty" style="padding:1rem;text-align:center">Aucune job non assignée</div>
<div v-if="btLasso" class="sb-bt-lasso" :style="{
left: Math.min(btLasso.x1, btLasso.x2) + 'px',
top: Math.min(btLasso.y1, btLasso.y2) + 'px',
width: Math.abs(btLasso.x2 - btLasso.x1) + 'px',
height: Math.abs(btLasso.y2 - btLasso.y1) + 'px'
}"></div>
</div>
</div>
</div>
</template>

View File

@ -1,243 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { PRICING_PRESETS } from 'src/composables/useJobOffers'
const props = defineProps({
modelValue: Boolean,
technicians: { type: Array, default: () => [] },
allTags: { type: Array, default: () => [] },
prefill: { type: Object, default: null }, // Pre-fill from existing job
})
const emit = defineEmits(['update:modelValue', 'create'])
const form = ref(resetForm())
const notifySms = ref(true)
function resetForm () {
return {
subject: '',
address: '',
customer: '',
scheduledDate: '',
startTime: '',
duration: 1,
priority: 'medium',
pricingPreset: 'standard',
offerMode: 'broadcast',
targetTechs: [],
requiredTags: [],
expiresAt: '',
isCustomerRequest: false,
salesOrder: '',
}
}
watch(() => props.modelValue, v => {
if (v) {
form.value = resetForm()
if (props.prefill) {
form.value.subject = props.prefill.subject || ''
form.value.address = props.prefill.address || ''
form.value.customer = props.prefill.customer || ''
form.value.scheduledDate = props.prefill.scheduledDate || ''
form.value.startTime = props.prefill.startTime || ''
form.value.duration = props.prefill.duration || 1
form.value.priority = props.prefill.priority || 'medium'
form.value.jobName = props.prefill.id || props.prefill.name || ''
}
}
})
const pricing = computed(() => PRICING_PRESETS[form.value.pricingPreset] || PRICING_PRESETS.standard)
const estimatedTotal = computed(() => pricing.value.displacement + Math.round(form.value.duration * pricing.value.hourlyRate))
const pricingOptions = Object.entries(PRICING_PRESETS).map(([k, v]) => ({
value: k, label: v.label, description: v.description,
}))
// Priorité Dispatch Job SOURCE UNIQUE (config/dispatch-priority) : « Moyenne » = ambre partout (fini le bleu divergent).
import { DISPATCH_PRIORITIES as priorityOptions } from 'src/config/dispatch-priority'
const modeOptions = [
{ value: 'broadcast', label: '📡 Diffusion', hint: 'Tous les techs disponibles' },
{ value: 'targeted', label: '🎯 Ciblé', hint: 'Techs spécifiques' },
{ value: 'pool', label: '📋 File d\'attente', hint: 'Premier arrivé, premier servi' },
]
function submit () {
emit('create', { ...form.value }, notifySms.value)
emit('update:modelValue', false)
}
</script>
<template>
<q-dialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" persistent>
<q-card class="create-offer-card" dark>
<q-card-section class="offer-modal-header">
<div class="offer-modal-title">
<span>📡</span> Créer une offre de travail
</div>
<q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" />
</q-card-section>
<q-separator dark />
<q-card-section class="offer-modal-body">
<!-- Subject -->
<q-input v-model="form.subject" label="Sujet / Description" dark filled dense class="offer-field" />
<!-- Address + Customer -->
<div class="offer-row">
<q-input v-model="form.address" label="Adresse" dark filled dense class="offer-field" style="flex:2" />
<q-input v-model="form.customer" label="Client" dark filled dense class="offer-field" style="flex:1" />
</div>
<!-- Date + Time + Duration -->
<div class="offer-row">
<q-input v-model="form.scheduledDate" label="Date" type="date" dark filled dense class="offer-field" />
<q-input v-model="form.startTime" label="Heure" type="time" dark filled dense class="offer-field" />
<q-input v-model.number="form.duration" label="Durée (h)" type="number" step="0.5" min="0.5" max="24" dark filled dense class="offer-field" />
</div>
<!-- Priority -->
<div class="offer-row">
<div class="offer-field-group">
<label class="offer-label">Priorité</label>
<div class="offer-priority-row">
<button v-for="p in priorityOptions" :key="p.value"
class="offer-priority-btn" :class="{ active: form.priority === p.value }"
:style="form.priority === p.value ? 'background:'+p.color+';color:#fff' : ''"
@click="form.priority = p.value">
{{ p.label }}
</button>
</div>
</div>
</div>
<!-- Offer mode -->
<div class="offer-field-group">
<label class="offer-label">Mode de diffusion</label>
<div class="offer-mode-row">
<button v-for="m in modeOptions" :key="m.value"
class="offer-mode-btn" :class="{ active: form.offerMode === m.value }"
@click="form.offerMode = m.value">
<span class="offer-mode-icon">{{ m.label.slice(0,2) }}</span>
<span>{{ m.label.slice(2) }}</span>
<small>{{ m.hint }}</small>
</button>
</div>
</div>
<!-- Targeted techs (if targeted mode) -->
<div v-if="form.offerMode === 'targeted'" class="offer-field-group">
<label class="offer-label">Techniciens ciblés</label>
<q-select v-model="form.targetTechs" :options="technicians.map(t => ({ label: t.fullName, value: t.id }))"
multiple emit-value map-options use-chips dark filled dense
option-value="value" option-label="label"
class="offer-field" />
</div>
<!-- Required tags -->
<q-select v-model="form.requiredTags" :options="allTags.map(t => t.label || t.name || t)"
multiple use-chips dark filled dense label="Tags / Compétences requises" class="offer-field" />
<q-separator dark class="q-my-sm" />
<!-- Pricing -->
<div class="offer-field-group">
<label class="offer-label">💰 Tarification</label>
<div class="offer-pricing-row">
<button v-for="p in pricingOptions" :key="p.value"
class="offer-pricing-btn" :class="{ active: form.pricingPreset === p.value }"
@click="form.pricingPreset = p.value">
<span class="offer-pricing-name">{{ p.label }}</span>
<small>{{ p.description }}</small>
</button>
</div>
</div>
<!-- Cost estimate -->
<div v-if="pricing.displacement > 0 || pricing.hourlyRate > 0" class="offer-cost-estimate">
<div class="offer-cost-line">
<span>Déplacement</span>
<span>{{ pricing.displacement }}$</span>
</div>
<div class="offer-cost-line">
<span>Main-d'œuvre ({{ form.duration }}h × {{ pricing.hourlyRate }}$/h)</span>
<span>{{ Math.round(form.duration * pricing.hourlyRate) }}$</span>
</div>
<div class="offer-cost-line offer-cost-total">
<span>Total estimé</span>
<span>{{ estimatedTotal }}$ {{ pricing.currency }}</span>
</div>
</div>
<!-- Customer request toggle -->
<q-toggle v-model="form.isCustomerRequest" label="Demande client (checkout portail)" dark dense class="q-mt-xs" />
<!-- Sales order link -->
<q-input v-if="form.isCustomerRequest" v-model="form.salesOrder" label="Bon de commande (Sales Order)" dark filled dense class="offer-field" />
<!-- Expiry -->
<q-input v-model="form.expiresAt" label="Expiration (optionnel)" type="datetime-local" dark filled dense class="offer-field" />
<!-- SMS notification toggle -->
<q-toggle v-model="notifySms" label="Notifier par SMS" dark dense class="q-mt-xs" />
</q-card-section>
<q-separator dark />
<q-card-actions align="right" class="offer-modal-footer">
<q-btn flat label="Annuler" dark @click="emit('update:modelValue', false)" />
<q-btn unelevated color="primary" label="📡 Diffuser l'offre" :disable="!form.subject" @click="submit" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<style lang="scss">
.create-offer-card {
width: 560px; max-width: 95vw; background: #12151e !important;
}
.offer-modal-header {
display: flex; align-items: center; justify-content: space-between;
.offer-modal-title { font-size: 1rem; font-weight: 600; display: flex; align-items: center; gap: 6px; }
}
.offer-modal-body { display: flex; flex-direction: column; gap: 8px; }
.offer-row { display: flex; gap: 8px; }
.offer-field { flex: 1; }
.offer-field-group { margin: 4px 0; }
.offer-label { font-size: 0.72rem; color: #9ca3af; font-weight: 500; margin-bottom: 4px; display: block; }
.offer-priority-row, .offer-mode-row, .offer-pricing-row {
display: flex; gap: 6px;
}
.offer-priority-btn, .offer-mode-btn, .offer-pricing-btn {
border: 1px solid rgba(255,255,255,0.08); border-radius: 6px;
background: rgba(255,255,255,0.04); color: #c8cad6;
cursor: pointer; transition: all 0.15s; text-align: left;
&:hover { background: rgba(255,255,255,0.08); }
&.active { border-color: #3b82f6; background: rgba(59,130,246,0.12); }
}
.offer-priority-btn { padding: 5px 14px; font-size: 0.78rem; font-weight: 500; }
.offer-mode-btn {
padding: 8px 12px; flex: 1; display: flex; flex-direction: column; gap: 2px;
font-size: 0.78rem;
small { font-size: 0.65rem; color: #6b7280; }
}
.offer-pricing-btn {
padding: 8px 12px; flex: 1; display: flex; flex-direction: column; gap: 2px;
font-size: 0.78rem;
.offer-pricing-name { font-weight: 600; }
small { font-size: 0.65rem; color: #6b7280; }
}
.offer-cost-estimate {
background: rgba(250,204,21,0.06); border: 1px solid rgba(250,204,21,0.12);
border-radius: 6px; padding: 8px 12px;
}
.offer-cost-line {
display: flex; justify-content: space-between; font-size: 0.78rem; color: #d4d4d8;
padding: 2px 0;
&.offer-cost-total { font-weight: 700; color: #fbbf24; border-top: 1px solid rgba(250,204,21,0.15); margin-top: 4px; padding-top: 6px; }
}
.offer-modal-footer { padding: 8px 16px; }
</style>

View File

@ -1,86 +0,0 @@
<script setup>
import { inject } from 'vue'
import { ICON } from 'src/composables/useHelpers'
import TagEditor from 'src/components/shared/TagEditor.vue'
const props = defineProps({ modelValue: Object }) // { job, subject, address, note, duration, priority, tags, latitude, longitude }
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const store = inject('store')
const MAPBOX_TOKEN = inject('MAPBOX_TOKEN')
const getTagColor = inject('getTagColor')
const onCreateTag = inject('onCreateTag')
const onUpdateTag = inject('onUpdateTag')
const onRenameTag = inject('onRenameTag')
const onDeleteTag = inject('onDeleteTag')
const searchAddr = inject('searchAddr')
const addrResults = inject('addrResults')
const selectAddr = inject('selectAddr')
function close () { emit('update:modelValue', null); emit('cancel') }
</script>
<template>
<div v-if="modelValue" class="sb-overlay" @click.self="close">
<div class="sb-modal sb-modal-wo">
<div class="sb-modal-hdr">
<span> Modifier la job</span>
<button class="sb-rp-close" @click="close"></button>
</div>
<div class="sb-modal-body sb-wo-body">
<div class="sb-wo-form">
<div class="sb-form-row">
<label class="sb-form-lbl">Titre</label>
<input class="sb-form-input" v-model="modelValue.subject" autofocus />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Adresse</label>
<div class="sb-addr-wrap">
<input class="sb-form-input" v-model="modelValue.address" placeholder="123 rue Exemple"
@input="searchAddr(modelValue.address)" @blur="setTimeout(()=>addrResults.length=0,200)" autocomplete="off" />
<div v-if="addrResults.length" class="sb-addr-dropdown">
<div v-for="a in addrResults" :key="a.address_full" class="sb-addr-item"
@mousedown.prevent="selectAddr(a, modelValue)">
<strong>{{ a.address_full }}</strong>
<span v-if="a.code_postal" class="sb-addr-cp">{{ a.code_postal }}</span>
<span v-if="a.ville" class="sb-addr-city">{{ a.ville }}</span>
</div>
</div>
</div>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Note</label>
<textarea class="sb-form-input" v-model="modelValue.note" rows="2" placeholder="Ex: chien dangereux, sonner 2x…" style="resize:vertical"></textarea>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Durée (h)</label>
<input type="number" class="sb-form-input" v-model.number="modelValue.duration" min="0.25" max="24" step="0.25" />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Priorité</label>
<select class="sb-form-sel" v-model="modelValue.priority">
<option value="low">Basse</option>
<option value="medium">Moyenne</option>
<option value="high">Haute</option>
</select>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Tags / Skills</label>
<TagEditor v-model="modelValue.tags" :all-tags="store.allTags" :get-color="getTagColor"
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
</div>
</div>
<div v-if="modelValue.latitude" class="sb-wo-minimap">
<!-- Plus de vignette Mapbox (aucun jeton) : coords + lien carte OSM/Google (le vrai rendu carte = MapLibre ailleurs). -->
<a :href="'https://www.openstreetmap.org/?mlat='+modelValue.latitude+'&mlon='+modelValue.longitude+'#map=16/'+modelValue.latitude+'/'+modelValue.longitude" target="_blank" rel="noopener" class="sb-minimap-link">
📍 {{ (+modelValue.latitude).toFixed(5) }}, {{ (+modelValue.longitude).toFixed(5) }} voir sur la carte
</a>
</div>
</div>
<div class="sb-modal-ftr">
<button class="sbf-primary-btn" @click="emit('confirm')"> Enregistrer</button>
<button class="sb-rp-btn" @click="close">Annuler</button>
</div>
</div>
</div>
</template>

View File

@ -1,153 +0,0 @@
<script setup>
import { inject, computed, ref } from 'vue'
import { localDateStr, startOfWeek, jobSpansDate, techDaySchedule, techDayCapacityH, fmtDur, expandRRule } from 'src/composables/useHelpers'
import { ABSENCE_REASONS } from 'src/composables/useTechManagement'
const props = defineProps({
anchorDate: Date,
filteredResources: Array,
todayStr: String,
selectedTechId: { type: String, default: null },
})
const emit = defineEmits(['go-to-day', 'select-tech', 'open-schedule'])
const TECH_COLORS = inject('TECH_COLORS')
const planningMode = inject('planningMode', ref(false))
function isDayToday (d) { return localDateStr(d) === props.todayStr }
const monthWeeks = computed(() => {
const first = new Date(props.anchorDate.getFullYear(), props.anchorDate.getMonth(), 1)
const last = new Date(props.anchorDate.getFullYear(), props.anchorDate.getMonth() + 1, 0)
const start = startOfWeek(first)
const end = new Date(last)
const dow = end.getDay()
if (dow !== 0) end.setDate(end.getDate() + (7 - dow))
const weeks = []; let cur = new Date(start)
while (cur <= end) {
const week = []
for (let i = 0; i < 7; i++) { week.push(new Date(cur)); cur.setDate(cur.getDate() + 1) }
weeks.push(week)
}
return weeks
})
function techsActiveOnDay (dateStr) {
return props.filteredResources.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) {
const jobIds = new Set()
props.filteredResources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr, t)).forEach(j => jobIds.add(j.id)))
return jobIds.size
}
function isTechAvailableOnDay (tech, dateStr) {
// Explicit absence
if (tech.status === 'off' && tech.absenceFrom) {
const from = tech.absenceFrom
const until = tech.absenceUntil || from
if (dateStr >= from && dateStr <= until) return false
}
// Schedule off-day
if (!techDaySchedule(tech, dateStr)) return false
return true
}
// Selected tech availability for planning mode
const selectedTech = computed(() => props.selectedTechId ? props.filteredResources.find(t => t.id === props.selectedTechId) : null)
function selectedTechDayInfo (dateStr) {
const tech = selectedTech.value
if (!tech) return null
// Absence check
const isExplicitAbsent = tech.status === 'off' && tech.absenceFrom && dateStr >= tech.absenceFrom && dateStr <= (tech.absenceUntil || tech.absenceFrom)
const sched = techDaySchedule(tech, dateStr)
const isScheduleOff = !sched
if (isExplicitAbsent) {
const r = ABSENCE_REASONS.find(x => x.value === tech.absenceReason)
return { type: 'absence', label: r ? r.label : 'Absent', icon: r ? r.icon : '⏸', color: 'var(--sb-sched-absence, #ef444466)' }
}
if (isScheduleOff) {
return { type: 'dayoff', label: 'Repos', icon: '📅', color: 'var(--sb-sched-dayoff, #6b728044)' }
}
// On-call / extra shifts
const extras = (tech.extraShifts || []).filter(s => {
if (!s.rrule || !s.startTime || !s.endTime) return false
const dates = expandRRule(s.rrule, s.from || dateStr, dateStr, dateStr, [])
return dates.includes(dateStr)
})
if (extras.length) {
const ex = extras[0]
return { type: 'oncall', label: `${ex.label || 'Garde'} ${ex.startTime}${ex.endTime}`, icon: '🔔', color: 'var(--sb-sched-oncall, #f59e0b44)' }
}
// Available
return { type: 'available', label: `${sched.start} ${sched.end}`, icon: '✓', color: 'var(--sb-sched-avail, #4ade8033)' }
}
function daySummary (dateStr) {
let present = 0, absent = 0, loadH = 0, capH = 0
props.filteredResources.forEach(tech => {
if (isTechAvailableOnDay(tech, dateStr)) {
present++
capH += techDayCapacityH(tech, dateStr)
loadH += tech.queue.filter(j => jobSpansDate(j, dateStr, tech)).reduce((s, j) => s + (parseFloat(j.duration) || 0), 0)
} else {
absent++
}
})
return { present, absent, loadH, capH }
}
</script>
<template>
<div class="sb-month-wrap">
<div class="sb-month-dow-hdr">
<div v-for="wd in ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim']" :key="wd" class="sb-month-dow">{{ wd }}</div>
</div>
<div v-for="(week, wi) in monthWeeks" :key="wi" class="sb-month-week">
<div v-for="day in week" :key="localDateStr(day)"
class="sb-month-day"
:class="{ 'sb-month-day-out': day.getMonth() !== anchorDate.getMonth(), 'sb-month-day-today': isDayToday(day) }"
@click="emit('go-to-day', day)">
<div class="sb-month-day-num">{{ day.getDate() }}</div>
<!-- Planning mode: selected tech availability -->
<div v-if="planningMode && selectedTech && selectedTechDayInfo(localDateStr(day))"
class="sb-month-avail" :class="'sb-month-avail-' + selectedTechDayInfo(localDateStr(day)).type"
:style="'background:' + selectedTechDayInfo(localDateStr(day)).color"
@click.stop="emit('open-schedule', selectedTech)"
:title="selectedTech.fullName + ': ' + selectedTechDayInfo(localDateStr(day)).label + ' — cliquer pour modifier'">
<span class="sb-month-avail-icon">{{ selectedTechDayInfo(localDateStr(day)).icon }}</span>
<span class="sb-month-avail-label">{{ selectedTechDayInfo(localDateStr(day)).label }}</span>
</div>
<div class="sb-month-stats">
<span class="sb-month-stat sb-month-stat-present" :title="daySummary(localDateStr(day)).present + ' tech(s) disponible(s)'">
👷 {{ daySummary(localDateStr(day)).present }}
</span>
<span v-if="daySummary(localDateStr(day)).absent" class="sb-month-stat sb-month-stat-absent" :title="daySummary(localDateStr(day)).absent + ' tech(s) absent(s)'">
{{ daySummary(localDateStr(day)).absent }}
</span>
</div>
<div class="sb-month-avatars">
<div v-for="tech in techsActiveOnDay(localDateStr(day))" :key="tech.id"
class="sb-month-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]"
:title="tech.fullName + ' — ' + tech.queue.filter(j=>jobSpansDate(j,localDateStr(day),tech)).length + ' job(s)'"
@click.stop="emit('select-tech', tech)">
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</div>
</div>
<div v-if="daySummary(localDateStr(day)).capH" class="sb-month-hours">
{{ fmtDur(daySummary(localDateStr(day)).loadH) }}/{{ fmtDur(daySummary(localDateStr(day)).capH) }}
</div>
<div v-if="dayJobCount(localDateStr(day))" class="sb-month-job-count">
{{ dayJobCount(localDateStr(day)) }} job{{ dayJobCount(localDateStr(day))>1?'s':'' }}
</div>
</div>
</div>
</div>
</template>

View File

@ -1,204 +0,0 @@
<script setup>
import { inject, computed } from 'vue'
import { fmtDur, shortAddr } from 'src/composables/useHelpers'
import { PRICING_PRESETS } from 'src/composables/useJobOffers'
const props = defineProps({
offers: Array,
loading: Boolean,
})
const emit = defineEmits(['accept', 'decline', 'cancel', 'offer-job', 'refresh', 'close'])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
function techName (id) {
const t = store.technicians.find(t => t.id === id)
return t ? t.fullName : id
}
function statusColor (status) {
return { open: '#4ade80', pending: '#facc15', accepted: '#60a5fa', expired: '#6b7280', cancelled: '#ef4444' }[status] || '#6b7280'
}
function statusLabel (status) {
return { open: 'Ouverte', pending: 'En attente', accepted: 'Acceptée', expired: 'Expirée', cancelled: 'Annulée' }[status] || status
}
const activeOffers = computed(() => props.offers.filter(o => o.status === 'open' || o.status === 'pending'))
const pastOffers = computed(() => props.offers.filter(o => o.status !== 'open' && o.status !== 'pending'))
</script>
<template>
<div class="offer-pool-panel">
<div class="offer-pool-header">
<h3>
<span class="offer-icon">📡</span> Offres de travail
<span v-if="activeOffers.length" class="offer-badge">{{ activeOffers.length }}</span>
</h3>
<div class="offer-pool-actions">
<button class="offer-btn offer-btn-sm" @click="emit('refresh')" title="Rafraîchir">🔄</button>
<button class="offer-btn offer-btn-sm" @click="emit('close')" title="Fermer"></button>
</div>
</div>
<div v-if="loading" class="offer-loading">Chargement des offres</div>
<!-- Active offers -->
<div v-if="activeOffers.length" class="offer-section">
<div class="offer-section-label">Actives</div>
<div v-for="offer in activeOffers" :key="offer.id" class="offer-card" :class="'offer-card-' + offer.priority">
<div class="offer-card-header">
<span class="offer-status-dot" :style="'background:'+statusColor(offer.status)"></span>
<span class="offer-subject">{{ offer.subject }}</span>
<span v-if="offer.priority === 'high'" class="offer-urgent">URGENT</span>
<span class="offer-mode-tag">{{ offer.offerMode === 'broadcast' ? '📡' : offer.offerMode === 'targeted' ? '🎯' : '📋' }}</span>
</div>
<div class="offer-card-details">
<div v-if="offer.address" class="offer-detail">📍 {{ shortAddr(offer.address) }}</div>
<div v-if="offer.scheduledDate" class="offer-detail">📅 {{ offer.scheduledDate }}{{ offer.startTime ? ' à ' + offer.startTime : '' }}</div>
<div class="offer-detail"> {{ fmtDur(offer.duration) }}</div>
<div v-if="offer.customer" class="offer-detail">👤 {{ offer.customer }}</div>
</div>
<!-- Pricing -->
<div v-if="offer.displacement > 0 || offer.hourlyRate > 0" class="offer-pricing">
<span class="offer-price-tag">💰</span>
<span v-if="offer.displacement > 0">{{ offer.displacement }}$ déplacement</span>
<span v-if="offer.displacement > 0 && offer.hourlyRate > 0"> + </span>
<span v-if="offer.hourlyRate > 0">{{ offer.hourlyRate }}$/h</span>
<span class="offer-total">= {{ offer.displacement + Math.round(offer.duration * offer.hourlyRate) }}$</span>
</div>
<!-- Target techs -->
<div v-if="offer.targetTechs.length" class="offer-targets">
<span class="offer-targets-label">Ciblés:</span>
<span v-for="tid in offer.targetTechs" :key="tid" class="offer-tech-chip"
:class="{ 'offer-tech-declined': offer.declinedTechs.some(d => d.techId === tid) }">
{{ techName(tid) }}
<span v-if="offer.declinedTechs.some(d => d.techId === tid)" class="offer-declined-x"></span>
</span>
</div>
<!-- Declined list -->
<div v-if="offer.declinedTechs.length" class="offer-declined-info">
{{ offer.declinedTechs.length }} décliné{{ offer.declinedTechs.length > 1 ? 's' : '' }}
</div>
<!-- Actions -->
<div class="offer-card-actions">
<button class="offer-btn offer-btn-accept" @click="emit('accept', offer)" title="Assigner manuellement"> Assigner</button>
<button class="offer-btn offer-btn-cancel" @click="emit('cancel', offer)" title="Annuler l'offre"> Annuler</button>
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="!loading && !activeOffers.length" class="offer-empty">
<div class="offer-empty-icon">📡</div>
<div>Aucune offre active</div>
<div class="offer-empty-hint">Créez une offre depuis un travail non-assigné ou le bouton ci-dessous</div>
<button class="offer-btn offer-btn-primary" @click="emit('offer-job')">+ Nouvelle offre</button>
</div>
<!-- Past offers (collapsed) -->
<details v-if="pastOffers.length" class="offer-section offer-past">
<summary class="offer-section-label">Historique ({{ pastOffers.length }})</summary>
<div v-for="offer in pastOffers.slice(0, 20)" :key="offer.id" class="offer-card offer-card-past">
<div class="offer-card-header">
<span class="offer-status-dot" :style="'background:'+statusColor(offer.status)"></span>
<span class="offer-subject">{{ offer.subject }}</span>
<span class="offer-status-label">{{ statusLabel(offer.status) }}</span>
<span v-if="offer.acceptedBy" class="offer-accepted-by"> {{ techName(offer.acceptedBy) }}</span>
</div>
</div>
</details>
</div>
</template>
<style lang="scss">
.offer-pool-panel {
display: flex; flex-direction: column; gap: 0;
height: 100%; overflow-y: auto;
background: var(--sb-panel-bg, #0e1117);
color: #e2e4ef;
font-size: 0.82rem;
}
.offer-pool-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px 8px; border-bottom: 1px solid rgba(255,255,255,0.06);
h3 { margin: 0; font-size: 0.95rem; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.offer-icon { font-size: 1.1rem; }
.offer-badge {
background: #4ade80; color: #000; font-size: 0.65rem; font-weight: 700;
padding: 1px 6px; border-radius: 10px; min-width: 18px; text-align: center;
}
}
.offer-pool-actions { display: flex; gap: 4px; }
.offer-btn {
border: none; border-radius: 6px; cursor: pointer; font-size: 0.78rem;
padding: 5px 10px; transition: all 0.15s; font-weight: 500;
background: rgba(255,255,255,0.06); color: #c8cad6;
&:hover { background: rgba(255,255,255,0.12); }
}
.offer-btn-sm { padding: 3px 7px; font-size: 0.72rem; }
.offer-btn-primary { background: #3b82f6; color: #fff; &:hover { background: #2563eb; } }
.offer-btn-accept { background: rgba(74,222,128,0.15); color: #4ade80; &:hover { background: rgba(74,222,128,0.25); } }
.offer-btn-cancel { background: rgba(239,68,68,0.1); color: #f87171; &:hover { background: rgba(239,68,68,0.2); } }
.offer-loading { padding: 24px; text-align: center; color: #6b7280; }
.offer-section { padding: 8px 14px; }
.offer-section-label {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
color: #6b7280; margin-bottom: 6px; font-weight: 600;
}
.offer-card {
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px; padding: 10px 12px; margin-bottom: 8px;
border-left: 3px solid #3b82f6;
&.offer-card-high { border-left-color: #ef4444; }
&.offer-card-past { opacity: 0.55; border-left-color: #4b5563; }
}
.offer-card-header {
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
}
.offer-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.offer-subject { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.offer-urgent {
font-size: 0.6rem; font-weight: 700; color: #ef4444;
background: rgba(239,68,68,0.15); padding: 1px 5px; border-radius: 3px;
}
.offer-mode-tag { font-size: 0.85rem; }
.offer-card-details {
display: flex; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 6px;
.offer-detail { font-size: 0.75rem; color: #9ca3af; white-space: nowrap; }
}
.offer-pricing {
background: rgba(250,204,21,0.08); border: 1px solid rgba(250,204,21,0.15);
border-radius: 5px; padding: 4px 8px; margin-bottom: 6px;
font-size: 0.75rem; color: #fbbf24; display: flex; align-items: center; gap: 4px;
.offer-total { margin-left: auto; font-weight: 700; color: #fcd34d; }
}
.offer-targets {
display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-bottom: 6px;
.offer-targets-label { font-size: 0.7rem; color: #6b7280; }
}
.offer-tech-chip {
font-size: 0.7rem; padding: 1px 6px; border-radius: 4px;
background: rgba(59,130,246,0.15); color: #93c5fd;
&.offer-tech-declined { background: rgba(239,68,68,0.1); color: #f87171; text-decoration: line-through; }
.offer-declined-x { color: #ef4444; margin-left: 2px; }
}
.offer-declined-info { font-size: 0.7rem; color: #f87171; margin-bottom: 4px; }
.offer-card-actions { display: flex; gap: 6px; justify-content: flex-end; }
.offer-status-label { font-size: 0.68rem; color: #6b7280; }
.offer-accepted-by { font-size: 0.72rem; color: #4ade80; }
.offer-empty {
padding: 32px 20px; text-align: center; color: #6b7280;
.offer-empty-icon { font-size: 2rem; margin-bottom: 8px; opacity: 0.4; }
.offer-empty-hint { font-size: 0.72rem; margin: 6px 0 14px; color: #4b5563; }
}
.offer-past { summary { cursor: pointer; } }
</style>

View File

@ -1,279 +0,0 @@
<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)">
<q-card style="width:560px;max-width:95vw" class="publish-modal">
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid rgba(255,255,255,0.06)">
<div class="col">
<div class="text-subtitle1 text-weight-bold">Publier & envoyer l'horaire par SMS</div>
</div>
<q-btn flat round dense icon="close" color="grey-5" @click="$emit('update:modelValue', false)" />
</q-card-section>
<q-card-section class="q-gutter-md">
<!-- Period -->
<div>
<div class="text-caption text-grey-5 q-mb-xs">Période</div>
<div class="row items-center q-gutter-sm">
<q-input v-model="startDate" type="date" dense outlined dark class="col" />
<span class="text-grey-5">&rarr;</span>
<q-input v-model="endDate" type="date" dense outlined dark class="col" />
</div>
</div>
<!-- Employees -->
<div>
<div class="text-caption text-grey-5 q-mb-xs">Employés</div>
<q-select v-model="selectedTechs" :options="techOptions" multiple use-chips
dense outlined dark emit-value map-options
option-value="value" option-label="label">
<template v-slot:selected-item="{ opt, removeAtIndex, index }">
<q-chip removable dense size="sm" color="green-8" text-color="white"
@remove="removeAtIndex(index)">
<q-avatar color="primary" text-color="white" size="18px" class="q-mr-xs">T</q-avatar>
{{ opt.label || opt }}
</q-chip>
</template>
</q-select>
</div>
<!-- Draft count info -->
<div v-if="draftCount > 0" class="text-caption" style="color:#a5b4fc">
{{ draftCount }} travaux brouillon à publier pour cette période
</div>
<div v-else class="text-caption text-grey-6">
Aucun brouillon à publier pour cette sélection
</div>
<!-- Include open jobs -->
<q-checkbox v-model="includeOpen" label="Inclure les postes ouverts (non assignés)"
dark dense color="primary" />
<!-- Additional message -->
<div>
<div class="text-caption text-grey-5 q-mb-xs">Message additionnel inclus dans le SMS envoyé aux employés</div>
<q-input v-model="extraMessage" type="textarea" dense outlined dark
placeholder="Message optionnel..." :input-style="{ minHeight: '48px' }" />
</div>
<!-- What the tech will receive -->
<div class="sms-preview-hint">
<q-icon name="info" size="xs" color="green-3" class="q-mr-xs" />
<span>Chaque SMS contient l'horaire + deux liens&nbsp;:
<b>📱&nbsp;Mes tâches</b> (application mobile) et
<b>📅&nbsp;Ajouter à mon calendrier</b>
(ajout automatique dans Google Agenda ou Apple Calendar se met à jour tout seul).
</span>
</div>
</q-card-section>
<q-card-actions class="q-px-md q-pb-md">
<q-btn unelevated label="Publier & Envoyer" color="negative" icon="send"
:loading="publishing" :disable="draftCount === 0 && !includeOpen"
@click="onPublish" />
<q-btn flat label="Ignorer" color="grey-5" @click="$emit('update:modelValue', false)" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Notify } from 'quasar'
import { publishJobs } from 'src/api/dispatch'
import { sendTestSms } from 'src/api/sms'
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
async function getMagicLink (techId) {
try {
const res = await fetch(`${HUB_URL}/magic-link/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tech_id: techId, ttl_hours: 72 }),
})
const data = await res.json()
return data.ok ? data.link : null
} catch { return null }
}
// Calendar subscription URL. webcal:// triggers native Calendar.app / Google
// Calendar "add subscription" flow on tap, so the tech's agenda auto-refreshes
// whenever dispatch updates jobs no re-send required.
async function getIcalUrl (techId) {
try {
const res = await fetch(`${HUB_URL}/dispatch/ical-token/${encodeURIComponent(techId)}`)
const data = await res.json()
if (!data?.url) return null
return HUB_URL.replace(/^https?:/, 'webcal:') + data.url
} catch { return null }
}
const props = defineProps({
modelValue: Boolean,
jobs: { type: Array, default: () => [] },
technicians: { type: Array, default: () => [] },
periodStart: { type: String, default: '' },
periodEnd: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'published'])
const startDate = ref('')
const endDate = ref('')
const selectedTechs = ref([])
const includeOpen = ref(true)
const extraMessage = ref('')
const publishing = ref(false)
const techOptions = computed(() =>
props.technicians
.filter(t => t.active !== false)
.map(t => ({ label: t.fullName || t.name, value: t.id }))
)
// Draft jobs matching period + selected techs
const draftJobs = computed(() => {
const start = startDate.value
const end = endDate.value
const techIds = new Set(selectedTechs.value)
return props.jobs.filter(j => {
if (j.published) return false
if (j.status === 'completed' || j.status === 'cancelled') return false
if (!j.scheduledDate) return includeOpen.value
if (start && j.scheduledDate < start) return false
if (end && j.scheduledDate > end) return false
if (techIds.size && j.assignedTech && !techIds.has(j.assignedTech)) return false
if (!includeOpen.value && !j.assignedTech) return false
return true
})
})
const draftCount = computed(() => draftJobs.value.length)
// Reset on open
watch(() => props.modelValue, (open) => {
if (open) {
startDate.value = props.periodStart
endDate.value = props.periodEnd
selectedTechs.value = techOptions.value.map(t => t.value)
includeOpen.value = true
extraMessage.value = ''
}
})
// Build SMS message per tech
function buildSmsForTech (tech, techJobs, magicLink = null, icalUrl = null) {
const byDate = {}
for (const j of techJobs) {
const d = j.scheduledDate || 'Non planifié'
if (!byDate[d]) byDate[d] = []
byDate[d].push(j)
}
const dayNames = { 0: 'Dim', 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven', 6: 'Sam' }
const lines = [`Horaire du ${fmtDate(startDate.value)} au ${fmtDate(endDate.value)}:`]
const sortedDates = Object.keys(byDate).sort()
for (const d of sortedDates) {
const dt = new Date(d + 'T12:00:00')
const dayLabel = isNaN(dt) ? d : `${dayNames[dt.getDay()]} ${dt.getDate()}`
const jobDescs = byDate[d].map(j => {
const time = j.startTime ? j.startTime.slice(0, 5) : ''
const dur = j.duration ? `${j.duration}h` : ''
const addr = j.address ? shortAddr(j.address) : ''
return [time, j.subject, dur, addr].filter(Boolean).join(' - ')
})
lines.push(`${dayLabel}: ${jobDescs.join('; ')}`)
}
if (extraMessage.value.trim()) lines.push(extraMessage.value.trim())
if (magicLink || icalUrl) lines.push('')
if (magicLink) lines.push(`📱 Mes tâches: ${magicLink}`)
if (icalUrl) lines.push(`📅 Ajouter à mon calendrier: ${icalUrl}`)
return lines.join('\n')
}
function fmtDate (d) {
if (!d) return '?'
const dt = new Date(d + 'T12:00:00')
return isNaN(dt) ? d : `${dt.getDate()}/${dt.getMonth() + 1}`
}
function shortAddr (addr) {
if (!addr) return ''
return addr.split(',')[0].trim().slice(0, 30)
}
async function onPublish () {
publishing.value = true
try {
// 1. Collect job names to publish
const jobNames = draftJobs.value.map(j => j.name || j.id)
if (jobNames.length === 0) {
Notify.create({ type: 'warning', message: 'Aucun brouillon à publier' })
publishing.value = false
return
}
// 2. Bulk publish via API
const result = await publishJobs(jobNames)
// 3. Send SMS to each selected tech (with magic link)
const techIds = new Set(selectedTechs.value)
let smsSent = 0
let smsFailed = 0
for (const tech of props.technicians) {
if (!techIds.has(tech.id)) continue
if (!tech.phone) continue
const techJobs = draftJobs.value.filter(j => j.assignedTech === tech.id)
if (!techJobs.length) continue
const [magicLink, icalUrl] = await Promise.all([
getMagicLink(tech.id),
getIcalUrl(tech.id),
])
const msg = buildSmsForTech(tech, techJobs, magicLink, icalUrl)
try {
await sendTestSms(tech.phone, msg, '', {
reference_doctype: 'Dispatch Technician',
reference_name: tech.name || tech.id,
})
smsSent++
} catch (e) {
console.warn(`SMS failed for ${tech.fullName}:`, e)
smsFailed++
}
}
// 4. Emit published event so parent updates store
emit('published', jobNames)
const parts = [`${result.published} travaux publiés`]
if (smsSent) parts.push(`${smsSent} SMS envoyés`)
if (smsFailed) parts.push(`${smsFailed} SMS échoués`)
Notify.create({ type: 'positive', message: parts.join(', '), timeout: 4000 })
emit('update:modelValue', false)
} catch (err) {
Notify.create({ type: 'negative', message: `Erreur: ${err.message}` })
} finally {
publishing.value = false
}
}
</script>
<style scoped>
.publish-modal {
background: #1a1e2e;
color: #e2e4ef;
}
.sms-preview-hint {
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.25);
border-radius: 6px;
padding: 8px 10px;
font-size: 11.5px;
line-height: 1.45;
color: #c7d2fe;
display: flex;
align-items: flex-start;
}
.sms-preview-hint b {
color: #e0e7ff;
font-weight: 600;
}
</style>

View File

@ -1,181 +0,0 @@
<script setup>
import { inject, ref, watch } from 'vue'
import { fmtDur, prioLabel, prioClass, ICON, legacyReplyUrl } from 'src/composables/useHelpers'
import { legacyTicketThread } from 'src/api/roster'
import TagEditor from 'src/components/shared/TagEditor.vue'
const props = defineProps({
panel: Object, // { mode, data: { job, tech } } or null
})
// Fil legacy (description + commentaires/réponses) chargé À LA DEMANDE au clic.
const thread = ref(null); const threadLoading = ref(false); const threadOpen = ref(false)
async function toggleThread () {
const id = props.panel?.data?.job?.legacyTicketId; if (!id) return
threadOpen.value = !threadOpen.value
if (!threadOpen.value || thread.value) return
threadLoading.value = true
try { thread.value = await legacyTicketThread(id) } catch (e) { thread.value = { error: String(e.message || e) } } finally { threadLoading.value = false }
}
function fmtThreadDate (iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) }
watch(() => props.panel?.data?.job?.legacyTicketId, () => { thread.value = null; threadOpen.value = false }) // reset quand on change de job
const emit = defineEmits([
'close', 'edit', 'move', 'geofix', 'unassign',
'set-end-date', 'remove-assistant', 'assign-pending',
'update-tags',
])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const todayISO = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) // pour le badge « en retard »
const getTagColor = inject('getTagColor')
const onCreateTag = inject('onCreateTag')
const onUpdateTag = inject('onUpdateTag')
const onRenameTag = inject('onRenameTag')
const onDeleteTag = inject('onDeleteTag')
</script>
<template>
<transition name="sb-slide-right">
<aside v-if="panel" class="sb-right">
<div class="sb-rp-hdr">
<span class="sb-rp-title">{{ {details:'Détails',pending:'Demande entrante'}[panel.mode] || 'Détails' }}</span>
<button class="sb-rp-close" @click="emit('close')"></button>
</div>
<!-- JOB DETAILS -->
<template v-if="panel.mode==='details'">
<div class="sb-rp-body">
<div class="sb-rp-color-bar" :style="'background:'+jobColor(panel.data?.job||{})"></div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Titre</span><strong>{{ panel.data?.job?.subject }}</strong></div>
<!-- Client + Lieu de service: clickable shortcuts. Customer opens
the ClientDetailPage in the same SPA (Vue Router hash route);
Service Location opens in ERPNext desk in a new tab since the
ops SPA doesn't have a dedicated SL detail page. The address
below is the free-text on the job; the persisted lat/lng
(used by the Mapbox marker) lives on the linked Service
Location discrepancy = bad geocode at SL creation time. -->
<div v-if="panel.data?.job?.customer" class="sb-rp-field">
<span class="sb-rp-lbl">Client</span>
<a class="sb-rp-link" :href="'#/clients/' + panel.data.job.customer">
{{ panel.data.job.customer }}
<span class="sb-rp-link-icon"></span>
</a>
</div>
<div v-if="panel.data?.job?.serviceLocation" class="sb-rp-field">
<span class="sb-rp-lbl">Lieu</span>
<!-- Lien in-app vers la fiche client, anchored on the SL.
ClientDetailPage reads ?location= from the query string
on mount and scrolls to that Service Location section.
Beats the bare ERPNext desk view which is unfriendly
(no abonnements, no totaux, just the raw doctype form). -->
<a class="sb-rp-link"
:href="'#/clients/' + panel.data.job.customer + '?location=' + panel.data.job.serviceLocation">
<code>{{ panel.data.job.serviceLocation }}</code>
<span class="sb-rp-link-icon"></span>
</a>
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.job?.address || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Durée</span>{{ fmtDur(panel.data?.job?.duration) }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Priorité</span>
<span :class="prioClass(panel.data?.job?.priority)">{{ prioLabel(panel.data?.job?.priority) }}</span>
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Technicien</span>{{ panel.data?.tech?.fullName || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Date planifiée</span>
{{ panel.data?.job?.scheduledDate || '—' }}
<span v-if="panel.data?.job?.endDate"> {{ panel.data.job.endDate }}</span>
<span v-if="panel.data?.job?.scheduledDate && panel.data.job.scheduledDate < todayISO && panel.data?.job?.status !== 'Completed'"
:style="{ marginLeft:'6px', color:'#fff', background:'#e53935', borderRadius:'4px', padding:'0 5px', fontSize:'10px', fontWeight:700 }"> en retard</span>
</div>
<div v-if="panel.data?.job?.assignedTech" class="sb-rp-field">
<span class="sb-rp-lbl">Date de fin</span>
<input type="date" class="sb-form-input" :value="panel.data?.job?.endDate || ''"
@change="emit('set-end-date', panel.data.job, $event.target.value)" style="margin-top:2px" />
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Statut</span>{{ panel.data?.job?.status }}</div>
<div v-if="panel.data?.job?.legacyTicketId" class="sb-rp-field">
<span class="sb-rp-lbl">Ticket legacy</span>
<a class="sb-rp-link" :href="legacyReplyUrl(panel.data.job)" target="_blank" rel="noopener"
:title="'Répondre / écrire dans le ticket #' + panel.data.job.legacyTicketId + ' (serveur legacy)'">
#{{ panel.data.job.legacyTicketId }}<span v-if="panel.data?.job?.legacyDept"> · {{ panel.data.job.legacyDept }}</span>
<span class="sb-rp-link-icon"></span>
</a>
</div>
<div v-if="panel.data?.job?.legacyDetail" class="sb-rp-field">
<span class="sb-rp-lbl">Détails du ticket</span>
<div style="white-space:pre-wrap;max-height:200px;overflow:auto;font-size:0.78rem;line-height:1.4;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:6px 8px;margin-top:2px">{{ panel.data.job.legacyDetail }}</div>
</div>
<!-- Fil complet du ticket legacy : commentaires / réponses des collaborateurs (chargé au clic) -->
<div v-if="panel.data?.job?.legacyTicketId" class="sb-rp-field">
<button class="sb-rp-btn" style="width:100%;text-align:left" @click="toggleThread">
💬 {{ threadOpen ? 'Masquer' : 'Voir' }} le fil du ticket / commentaires
<span v-if="thread && thread.count != null" style="opacity:.7">({{ thread.count }})</span>
</button>
<div v-if="threadOpen" style="margin-top:6px;max-height:300px;overflow:auto">
<div v-if="threadLoading" style="font-size:.78rem;opacity:.7;padding:4px">Chargement</div>
<div v-else-if="thread && thread.error" style="font-size:.78rem;color:#ef4444;padding:4px">Erreur : {{ thread.error }}</div>
<div v-else-if="thread && !thread.messages?.length" style="font-size:.78rem;opacity:.7;padding:4px">Aucun message.</div>
<div v-for="(m, i) in (thread?.messages || [])" :key="i" style="border-left:2px solid rgba(255,255,255,0.12);padding:3px 8px;margin-bottom:6px">
<div style="font-size:.68rem;opacity:.7"><b>{{ m.author }}</b> · {{ fmtThreadDate(m.at) }}</div>
<div style="white-space:pre-wrap;font-size:.76rem;line-height:1.35">{{ m.text }}</div>
</div>
</div>
</div>
<div v-if="panel.data?.job?.note" class="sb-rp-field"><span class="sb-rp-lbl">Note</span>{{ panel.data.job.note }}</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Tags</span>
<TagEditor v-if="panel.data?.job"
:model-value="panel.data.job.tags || []"
@update:model-value="v => emit('update-tags', panel.data.job, v)"
:all-tags="store.allTags" :get-color="getTagColor"
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
</div>
<div v-if="panel.data?.job?.assistants?.length" class="sb-rp-field">
<span class="sb-rp-lbl">Assistants</span>
<div v-for="a in panel.data.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="emit('remove-assistant', panel.data.job.id, a.techId)"></button>
</div>
</div>
</div>
<div class="sb-rp-actions">
<button class="sb-rp-primary" @click="emit('edit', panel.data.job)"> Modifier</button>
<button class="sb-rp-btn" @click="emit('move', panel.data.job, panel.data.tech?.id)"> Déplacer / Réassigner</button>
<button class="sb-rp-btn" @click="emit('geofix', panel.data.job)">📍 Géofixer sur la carte</button>
<a v-if="legacyReplyUrl(panel.data?.job)" class="sb-rp-btn" :href="legacyReplyUrl(panel.data.job)" target="_blank" rel="noopener" style="text-decoration:none;text-align:center">📝 Répondre dans legacy</a>
<a v-if="panel.data?.job?.legacyActivationUrl" class="sb-rp-btn" :href="panel.data.job.legacyActivationUrl" target="_blank" rel="noopener" style="text-decoration:none;text-align:center;background:#7e3ff2;color:#fff;border-color:#7e3ff2" title="Connecter / activer le(s) STB sur Ministra (lien legacy du ticket)">📺 Activer STB (Ministra)</a>
<button v-if="panel.data?.job?.assignedTech" class="sb-rp-btn sb-ctx-warn" @click="emit('unassign', panel.data.job)"> Désaffecter</button>
</div>
</template>
<!-- PENDING REQUEST -->
<template v-if="panel.mode==='pending'">
<div class="sb-rp-body">
<div class="sb-rp-color-bar" :style="'background:'+(panel.data?.urgency==='urgent'?'#ef4444':'#f59e0b')"></div>
<div v-if="panel.data?.urgency==='urgent'" class="sb-rp-urgent-tag">🚨 Urgent</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Client</span><strong>{{ panel.data?.customer_name }}</strong></div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Téléphone</span>{{ panel.data?.phone || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Service</span>{{ panel.data?.service_type }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Problème</span>{{ panel.data?.problem_type || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.address }}</div>
<div v-if="panel.data?.budget_label" class="sb-rp-field"><span class="sb-rp-lbl">Budget</span>{{ panel.data?.budget_label }}</div>
<div class="sbf-title" style="margin-top:0.75rem">Assigner à</div>
<div class="sb-assign-grid">
<button v-for="tech in store.technicians" :key="tech.id"
class="sb-assign-btn" :style="'border-color:'+TECH_COLORS[tech.colorIdx]"
@click="emit('assign-pending', tech.id)">
<span class="sb-assign-dot" :style="'background:'+TECH_COLORS[tech.colorIdx]"></span>
{{ tech.fullName }}
</button>
</div>
</div>
</template>
</aside>
</transition>
</template>

View File

@ -1,11 +0,0 @@
<script setup>
defineProps({
pos: { type: Object, default: null },
})
</script>
<template>
<div v-if="pos" class="sb-ctx" :style="'left:'+pos.x+'px;top:'+pos.y+'px'" @click.stop="()=>{}">
<slot />
</div>
</template>

View File

@ -1,28 +0,0 @@
<script setup>
const props = defineProps({
modelValue: { type: Boolean, default: false },
wide: { type: Boolean, default: false },
overlayClass: { type: String, default: '' },
modalClass: { type: String, default: '' },
bodyStyle: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const close = () => emit('update:modelValue', false)
</script>
<template>
<div v-if="modelValue" class="sb-overlay" :class="overlayClass" @click.self="close">
<div class="sb-modal" :class="[{ 'sb-modal-wide': wide }, modalClass]">
<div class="sb-modal-hdr">
<slot name="header" />
<button class="sb-rp-close" @click="close"></button>
</div>
<div class="sb-modal-body" :style="bodyStyle">
<slot />
</div>
<div class="sb-modal-ftr">
<slot name="footer" />
</div>
</div>
</div>
</template>

View File

@ -1,177 +0,0 @@
<script setup>
import { inject, ref } from 'vue'
import { ICON, fmtDur, shortAddr, jobStatusIcon, dayLoadColor, stOf } from 'src/composables/useHelpers'
const props = defineProps({
tech: Object,
segments: Array, // from techDayJobsWithTravel
hourTicks: Array,
totalW: Number,
pxPerHr: Number,
hStart: Number,
hEnd: Number,
rowH: Number,
isSelected: Boolean,
isElevated: Boolean,
dropGhostX: { type: Number, default: null },
})
const emit = defineEmits([
'select-tech', 'ctx-tech', 'open-tech-tags', 'drag-tech-start', 'reorder-drop',
'timeline-dragover', 'timeline-dragleave', 'timeline-drop',
'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx',
'assist-ctx', 'hover-job', 'unhover-job',
'block-move', 'block-resize',
'open-absence', 'end-absence',
'absence-resize',
'ghost-click', 'ghost-materialize',
'open-schedule',
])
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const selectedJob = inject('selectedJob')
const hoveredJobId = inject('hoveredJobId')
const periodLoadH = inject('periodLoadH')
const techPeriodCapacityH = inject('techPeriodCapacityH')
const techDayEndH = inject('techDayEndH')
const getTagColor = inject('getTagColor')
const isJobMultiSelected = inject('isJobMultiSelected')
const planningMode = inject('planningMode', ref(false))
</script>
<template>
<div class="sb-row" :class="{ 'sb-row-sel': isSelected, 'sb-row-elevated': isElevated, 'sb-row-absent': tech.status === 'off' }"
:style="'height:'+rowH+'px'" :data-tech-id="tech.id">
<!-- Resource cell -->
<div class="sb-res-cell" @click="emit('select-tech', tech)" @contextmenu.prevent="emit('ctx-tech', $event, tech)"
draggable="true" @dragstart="emit('drag-tech-start', $event, tech)"
@dragover.prevent="()=>{}" @drop.prevent="emit('reorder-drop', $event, tech)">
<div v-if="tech.resourceType==='material'" class="sb-avatar sb-avatar-mat" title="Ressource matérielle">
{{ ({Véhicule:'🚛',Outil:'🔧',Salle:'🏢',Équipement:'📦',Nacelle:'🏗️',Grue:'🏗️',Fusionneuse:'🔧',OTDR:'📡'})[tech.resourceCategory||tech.fullName] || '🔧' }}
</div>
<div v-else class="sb-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]">
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</div>
<div class="sb-res-info">
<div class="sb-res-name">{{ tech.fullName }}
<span v-for="t in (tech.tags||[]).slice(0,3)" :key="t" class="sb-res-tag-dot" :style="'background:'+getTagColor(t)" :title="t"></span>
</div>
<div class="sb-res-sub">
<span class="sb-st" :class="stOf(tech).cls">{{ stOf(tech).label }}</span>
<span v-if="tech.status === 'off' && tech.absenceReason" class="sb-res-absence-tag">{{ tech.absenceReason }}</span>
<span class="sb-load">{{ fmtDur(periodLoadH(tech)) }}/{{ fmtDur(techPeriodCapacityH(tech)) }}</span>
</div>
<div class="sb-util-bar">
<div class="sb-util-fill" :style="{ width: Math.min(100,periodLoadH(tech)/techPeriodCapacityH(tech)*100)+'%', background: dayLoadColor(periodLoadH(tech)/techPeriodCapacityH(tech)) }"></div>
</div>
<!-- Hover action overlay -->
<div class="sb-res-actions">
<button @click.stop="emit('open-tech-tags', tech)" title="Tags / Skills">#</button>
<button @click.stop="emit('open-schedule', tech)" title="Horaire hebdo">🗓</button>
<button v-if="tech.status !== 'off'" @click.stop="emit('open-absence', tech)" title="Mettre en absence"></button>
<button v-else @click.stop="emit('end-absence', tech)" title="Fin d'absence" class="sb-act-play"></button>
</div>
</div>
</div>
<!-- Timeline -->
<div class="sb-timeline" :style="'width:'+totalW+'px'"
@dragover.prevent="emit('timeline-dragover', $event, tech)"
@dragleave="emit('timeline-dragleave', $event)"
@drop.prevent="emit('timeline-drop', $event, tech)">
<!-- Hour guides -->
<div v-for="tick in hourTicks.filter(t=>!t.isDay)" :key="'hg-'+tick.x"
class="sb-hour-guide" :style="'left:'+tick.x+'px'"></div>
<template v-for="h in (hEnd - hStart)" :key="'qg-'+h">
<div v-for="q in [1,2,3]" :key="'q-'+h+'-'+q" class="sb-quarter-guide"
:style="'left:'+(((h + q*0.25) * pxPerHr))+'px'"></div>
</template>
<div class="sb-capacity-line" :style="'left:'+((techDayEndH(tech) - hStart) * pxPerHr)+'px'" :title="fmtDur(techPeriodCapacityH(tech))"></div>
<div v-if="dropGhostX!=null" class="sb-drop-line" :style="'left:'+dropGhostX+'px'"></div>
<!-- Shift availability background blocks (only in planning mode) -->
<template v-if="planningMode" v-for="seg in segments.filter(s => s.type === 'shift')" :key="'shift-'+seg.startH+(seg.isOnCall?'-oncall':'')">
<div class="sb-block-shift" :class="{ 'sb-block-shift-oncall': seg.isOnCall }" :style="seg.style" :title="seg.label + ' — cliquer pour modifier'"
style="pointer-events:auto;cursor:pointer" @click.stop="emit('open-schedule', tech)">
<span class="sb-shift-label">{{ seg.label }}</span>
</div>
</template>
<template v-for="seg in segments.filter(s => s.type !== 'shift' && !(s._isDayOff && !planningMode))" :key="seg.type+'-'+(seg.job?.id||'x')+(seg.isAssist?'-a':'')+(seg.type==='travel'?'-t':'')">
<!-- Absence block -->
<div v-if="seg.type==='absence'" class="sb-block sb-block-absence" :style="seg.style"
:title="`${seg.reasonIcon} ${seg.reasonLabel}${seg.until && seg.until !== seg.from ? ' ('+seg.from+' → '+seg.until+')' : ''}`">
<div class="sb-absence-inner">
<span class="sb-absence-icon">{{ seg.reasonIcon }}</span>
<span class="sb-absence-label">{{ seg.reasonLabel }}</span>
<span v-if="seg.until && seg.until !== seg.from" class="sb-absence-dates">{{ seg.from }} {{ seg.until }}</span>
</div>
<div class="sb-resize-handle sb-resize-left" @mousedown.stop.prevent="emit('absence-resize', $event, seg, tech, 'left')"></div>
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('absence-resize', $event, seg, tech, 'right')"></div>
</div>
<!-- Travel -->
<div v-else-if="seg.type==='travel'" class="sb-travel-trail"
:class="[seg.fromRoute?'sb-travel-route':'sb-travel-est', seg.isAssist?'sb-travel-assist':'']"
:style="{ ...seg.style, background:seg.color+(seg.fromRoute?'40':'22'), borderLeft:'2px solid '+seg.color+(seg.fromRoute?'88':'44') }">
<span v-if="parseFloat(seg.style.width)>36" class="sb-travel-lbl">{{ seg.fromRoute?'':'~' }}{{ seg.travelMin }}min</span>
</div>
<!-- Assist block -->
<div v-else-if="seg.type==='assist'" class="sb-block sb-block-assist"
:class="{ 'sb-block-assist-pinned':seg.assistPinned, 'sb-block-sel':selectedJob?.isAssist&&selectedJob?.job?.id===seg.job.id&&selectedJob?.assistTechId===seg.assistTechId, 'sb-block-linked':(selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist)||hoveredJobId===seg.job.id }"
:style="{ ...seg.style, background:((selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist)||hoveredJobId===seg.job.id)?jobColor(seg.job)+'dd':(seg.assistPinned?jobColor(seg.job)+'99':jobColor(seg.job)+'44') }"
:draggable="seg.assistPinned?'true':'false'"
@dragstart="seg.assistPinned && emit('job-dragstart',$event,seg.job,tech.id,true)"
@mouseenter="emit('hover-job',seg.job.id)" @mouseleave="emit('unhover-job')"
@click.stop="emit('job-click',seg.job,seg.job.assignedTech,true,seg.assistTechId,$event)"
@dblclick.stop="emit('job-dblclick',seg.job)"
@contextmenu.prevent="emit('assist-ctx',$event,seg.job,seg.assistTechId)">
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)"></div>
<div class="sb-block-inner">
<div class="sb-block-title"><span v-if="seg.assistPinned" class="sb-block-pin" title="Priorisé" v-html="ICON.pin"></span>{{ seg.assistNote||seg.job.subject }}</div>
<div class="sb-block-meta">{{ fmtDur(seg.assistDur) }} · {{ seg.job.subject }}{{ seg.job.address?' · '+shortAddr(seg.job.address):'' }}</div>
</div>
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'assist',seg.assistTechId)"></div>
</div>
<!-- Job block -->
<!-- Ghost (recurring) block -->
<div v-else-if="seg._isGhost" class="sb-block sb-block-ghost"
:style="{ ...seg.style, background:jobColor(seg.job)+'44', borderColor:jobColor(seg.job)+'88' }"
@click.stop="emit('ghost-click',seg._templateJob,seg.job.scheduledDate,tech.id)"
@dblclick.stop="emit('ghost-materialize',seg._templateJob,seg.job.scheduledDate,tech.id)">
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)+'88'"></div>
<div class="sb-block-inner">
<div class="sb-block-title"><span class="sb-ghost-icon">🔄</span> {{ seg.job.subject }}</div>
<div class="sb-block-meta">{{ fmtDur(seg.job.duration) }} · récurrent</div>
</div>
</div>
<!-- Regular job block -->
<div v-else class="sb-block"
:class="{ 'sb-block-done':seg.job.status==='completed', 'sb-block-draft':!seg.job.published, 'sb-block-sel':selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist, 'sb-block-multi':isJobMultiSelected(seg.job.id), 'sb-block-linked':selectedJob?.job?.id===seg.job.id&&selectedJob?.isAssist, 'sb-block-team':seg.job.assistants?.length }"
:style="{ ...seg.style, background:jobColor(seg.job)+'dd' }"
:data-job-id="seg.job.id" draggable="true"
@dragstart="emit('job-dragstart',$event,seg.job,tech.id,false)"
@mouseenter="emit('hover-job',seg.job.id)" @mouseleave="emit('unhover-job')"
@click.stop="emit('job-click',seg.job,tech.id,false,null,$event)"
@dblclick.stop="emit('job-dblclick',seg.job)"
@contextmenu.prevent="emit('job-ctx',$event,seg.job,tech.id)">
<div class="sb-move-handle" @mousedown.stop.prevent="emit('block-move',$event,seg.job,$event.target.parentElement)" title="Déplacer"></div>
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)"></div>
<div class="sb-block-inner">
<div class="sb-block-title"><span v-if="seg.pinned" class="sb-block-pin" title="Heure fixée" v-html="ICON.pin"></span>{{ seg.job.subject }}</div>
<div class="sb-block-meta">{{ seg.pinnedTime||'' }}{{ seg.pinnedTime?' · ':'' }}{{ fmtDur(seg.job.duration) }}</div>
<div v-if="seg.job.address" class="sb-block-addr"><span v-html="ICON.mapPin"></span> {{ shortAddr(seg.job.address) }}</div>
</div>
<div v-if="seg.job.assistants?.length" class="sb-block-assistants">
<span v-for="a in seg.job.assistants" :key="a.techId" class="sb-assist-badge"
:style="'background:'+TECH_COLORS[$root?.$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>
<span v-if="jobStatusIcon(seg.job).svg" class="sb-block-status-icon" :class="jobStatusIcon(seg.job).cls" :title="seg.job.status" v-html="jobStatusIcon(seg.job).svg"></span>
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'job')"></div>
</div>
</template>
</div>
</div>
</template>

View File

@ -1,225 +0,0 @@
<script setup>
import { inject, ref } from 'vue'
import {
localDateStr, fmtDate, fmtDur, shortAddr, dayLoadColor, stOf,
ICON, jobSpansDate, techDayCapacityH, techDaySchedule, timeToH, expandRRule,
} from 'src/composables/useHelpers'
import { ABSENCE_REASONS } from 'src/composables/useTechManagement'
const props = defineProps({
filteredResources: Array,
dayColumns: Array,
selectedTechId: String,
dropGhost: Object,
todayStr: String,
colW: { type: Number, default: 0 }, // fixed column width (px), 0 = flex
})
const emit = defineEmits([
'go-to-day', 'select-tech', 'ctx-tech',
'tech-reorder-start', 'tech-reorder-drop',
'cal-drop', 'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx',
'clear-filters',
'open-absence', 'end-absence', 'open-schedule',
'ghost-click', 'ghost-materialize',
])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const selectedJob = inject('selectedJob')
const isJobMultiSelected = inject('isJobMultiSelected')
const ghostOccurrencesForDate = inject('ghostOccurrencesForDate', () => () => [])
const getTagColor = inject('getTagColor')
const planningMode = inject('planningMode', ref(false))
function isDayToday (d) { return localDateStr(d) === props.todayStr }
function isExplicitAbsent (tech, d) {
if (tech.status !== 'off' || !tech.absenceFrom) return false
const ds = localDateStr(d)
const from = tech.absenceFrom
const until = tech.absenceUntil || from
return ds >= from && ds <= until
}
function isScheduleOff (tech, d) {
const ds = localDateStr(d)
return !techDaySchedule(tech, ds)
}
function isAbsentOnDay (tech, d) {
return isExplicitAbsent(tech, d) || isScheduleOff(tech, d)
}
// Returns absence info for a given tech+day: { icon, label, isFullDay, hours, timeRange, remainH }
function absenceInfo (tech, d) {
const ds = localDateStr(d)
// Schedule off-day (always full day)
if (isScheduleOff(tech, d) && !isExplicitAbsent(tech, d)) {
return { icon: '📅', label: 'Repos', isFullDay: true, hours: 0, timeRange: null, remainH: 0 }
}
// Explicit absence
const r = ABSENCE_REASONS.find(x => x.value === tech.absenceReason)
const icon = r ? r.icon : '⏸'
const label = r ? r.label : 'Absent'
const sched = techDaySchedule(tech, ds)
const schedStartH = sched ? sched.startH : 8
const schedEndH = sched ? sched.endH : 16
const schedHours = sched ? sched.hours : 8
const absStartH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : schedStartH
const absEndH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : schedEndH
const absHours = Math.max(0, absEndH - absStartH)
const isFullDay = absStartH <= schedStartH && absEndH >= schedEndH
const remainH = Math.max(0, schedHours - absHours)
const fmt = h => { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return hh + 'h' + (mm ? (mm < 10 ? '0' : '') + mm : '') }
const timeRange = !isFullDay ? fmt(absStartH) + '' + fmt(absEndH) : null
return { icon, label, isFullDay, hours: absHours, timeRange, remainH }
}
// Planning mode: schedule availability info per tech/day
function scheduleInfoForDay (tech, d) {
const ds = localDateStr(d)
const sched = techDaySchedule(tech, ds)
const info = { available: false, label: '', start: '', end: '', isOnCall: false, onCallLabel: '' }
if (sched) {
info.available = true
info.start = sched.start
info.end = sched.end
info.label = `${sched.start} ${sched.end}`
}
// Check for on-call / extra shifts
const extras = (tech.extraShifts || []).filter(s => {
if (!s.rrule || !s.startTime || !s.endTime) return false
const dates = expandRRule(s.rrule, s.from || ds, ds, ds, [])
return dates.includes(ds)
})
if (extras.length) {
const ex = extras[0]
info.isOnCall = true
info.onCallLabel = `${ex.label || 'Garde'}: ${ex.startTime} ${ex.endTime}`
}
return info
}
defineExpose({ isDayToday })
</script>
<template>
<div class="sb-grid sb-grid-cal" :style="colW ? 'min-width:'+(200 + colW * dayColumns.length)+'px' : ''">
<!-- Header -->
<div class="sb-grid-hdr">
<div class="sb-res-hdr">Ressources <span class="sbf-count">{{ filteredResources.length }}</span></div>
<div class="sb-cal-hdr" :style="colW ? 'flex:none;width:'+(colW * dayColumns.length)+'px' : ''">
<div v-for="d in dayColumns" :key="'ch-'+localDateStr(d)"
class="sb-cal-hdr-cell" :class="{ 'sb-col-today': isDayToday(d) }"
:style="colW ? 'flex:none;width:'+colW+'px' : ''"
style="cursor:pointer" @click="emit('go-to-day', d)">
<span class="sb-cal-wd">{{ fmtDate(d, {weekday:'short'}) }}</span>
<span class="sb-cal-dn" :class="{ 'sb-today-bubble': isDayToday(d) }">{{ d.getDate() }}</span>
</div>
</div>
</div>
<!-- Loading / empty -->
<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="emit('clear-filters')">Réinitialiser</button>
</div>
<!-- Rows -->
<div v-for="tech in filteredResources" :key="tech.id"
class="sb-row sb-row-cal" :class="{ 'sb-row-sel': selectedTechId===tech.id, 'sb-row-absent': tech.status==='off' }">
<div class="sb-res-cell" @click="emit('select-tech', tech)" @contextmenu.prevent="emit('ctx-tech', $event, tech)"
draggable="true" @dragstart="emit('tech-reorder-start', $event, tech)"
@dragover.prevent="()=>{}" @drop.prevent="emit('tech-reorder-drop', $event, tech)">
<div class="sb-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]">
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</div>
<div class="sb-res-info">
<div class="sb-res-name">{{ tech.fullName }}
<span v-for="t in (tech.tags||[]).slice(0,3)" :key="t" class="sb-res-tag-dot" :style="'background:'+getTagColor(t)" :title="t"></span>
</div>
<div class="sb-res-sub">
<span class="sb-st" :class="stOf(tech).cls">{{ stOf(tech).label }}</span>
<span v-if="tech.status === 'off' && tech.absenceReason" class="sb-res-absence-tag">{{ tech.absenceReason }}</span>
</div>
<div class="sb-res-actions">
<button @click.stop="emit('open-schedule', tech)" title="Horaire hebdo">🗓</button>
<button v-if="tech.status !== 'off'" @click.stop="emit('open-absence', tech)" title="Mettre en absence"></button>
<button v-else @click.stop="emit('end-absence', tech)" title="Fin d'absence" class="sb-act-play"></button>
</div>
</div>
</div>
<div class="sb-cal-row" :style="colW ? 'flex:none;width:'+(colW * dayColumns.length)+'px' : ''">
<div v-for="d in dayColumns" :key="localDateStr(d)"
class="sb-cal-cell" :class="{ 'sb-bg-today': isDayToday(d), 'sb-bg-alt': dayColumns.indexOf(d)%2===1, 'sb-cal-absent': isExplicitAbsent(tech, d) || (planningMode && isScheduleOff(tech, d) && !isExplicitAbsent(tech, d)) }"
:style="colW ? 'flex:none;width:'+colW+'px' : ''"
:data-date-str="localDateStr(d)"
@dblclick="emit('go-to-day', d)"
@dragover.prevent="()=>{}" @dragleave="()=>{}"
@drop.prevent="emit('cal-drop', $event, tech, localDateStr(d))">
<div v-if="dropGhost?.techId===tech.id && dropGhost.dateStr===localDateStr(d)" class="sb-cal-drop"></div>
<!-- Planning mode: availability band -->
<div v-if="planningMode && !isAbsentOnDay(tech, d) && scheduleInfoForDay(tech, d).available"
class="sb-sched-band sb-sched-available"
@click.stop="emit('open-schedule', tech)"
:title="'Disponible: ' + scheduleInfoForDay(tech, d).label + ' — cliquer pour modifier'">
<span class="sb-sched-time">{{ scheduleInfoForDay(tech, d).label }}</span>
</div>
<div v-if="planningMode && scheduleInfoForDay(tech, d).isOnCall"
class="sb-sched-band sb-sched-oncall"
@click.stop="emit('open-schedule', tech)"
:title="scheduleInfoForDay(tech, d).onCallLabel + ' — cliquer pour modifier'">
<span class="sb-sched-time">🔔 {{ scheduleInfoForDay(tech, d).onCallLabel }}</span>
</div>
<!-- Explicit absences always shown; schedule off-days only in planning mode -->
<div v-if="isExplicitAbsent(tech, d)" class="sb-chip sb-chip-absence sb-chip-absence-full"
:title="absenceInfo(tech, d).label + (absenceInfo(tech, d).timeRange ? ' ' + absenceInfo(tech, d).timeRange : '')">
<div class="sb-chip-line1">{{ absenceInfo(tech, d).icon }} {{ absenceInfo(tech, d).label }}</div>
<div v-if="absenceInfo(tech, d).isFullDay" class="sb-chip-line2 sb-absence-detail">Journée complète</div>
<div v-else class="sb-chip-line2 sb-absence-detail">
{{ absenceInfo(tech, d).timeRange }} · reste {{ fmtDur(absenceInfo(tech, d).remainH) }}
</div>
</div>
<template v-for="job in [...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech)), ...(tech.assistJobs||[]).filter(j=>jobSpansDate(j,localDateStr(d),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})), ...ghostOccurrencesForDate(tech, localDateStr(d))]" :key="job.id+(job._isAssistChip?'-a':'')">
<div v-if="job._isGhost" class="sb-chip sb-chip-ghost"
:style="'border-left-color:'+jobColor(job)+';background:'+jobColor(job)+'33;color:#ffffffaa;border-style:dashed'"
@click.stop="emit('ghost-click', job._templateJob||job, localDateStr(d), tech.id)"
@dblclick.stop="emit('ghost-materialize', job._templateJob||job, localDateStr(d), tech.id)">
<div class="sb-chip-line1">🔄 {{ job.subject }}</div>
<div class="sb-chip-line2">{{ fmtDur(job.duration) }} · récurrent</div>
</div>
<div v-else class="sb-chip"
:class="{ 'sb-chip-sel': selectedJob?.job?.id===job.id, 'sb-chip-multi': isJobMultiSelected(job.id), 'sb-chip-assist': job._isAssistChip }"
:data-job-id="job.id"
:style="'border-left-color:'+jobColor(job)+';background:'+jobColor(job)+'cc;color:#fff'"
:draggable="job._isAssistChip ? 'false' : 'true'"
@dragstart="!job._isAssistChip && emit('job-dragstart', $event, job, tech.id)"
@click.stop="emit('job-click', job, tech.id, false, null, $event)"
@dblclick.stop="emit('job-dblclick', job)"
@contextmenu.prevent="emit('job-ctx', $event, job, tech.id)">
<div class="sb-chip-line1">
<span v-if="job.priority==='high'" class="sb-chip-urgent"></span>
<span v-if="job._isAssistChip" class="sb-chip-assist-tag" v-html="ICON.pin"></span>
{{ job.subject }}
</div>
<div v-if="job.address" class="sb-chip-line2"><span v-html="ICON.mapPin"></span> {{ shortAddr(job.address) }}</div>
</div>
</template>
<!-- Day load bar -->
<div v-if="[...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech))].length" class="sb-day-load">
<div class="sb-day-load-track">
<div class="sb-day-load-fill" :style="{ width: Math.min(100, tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech)).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/(techDayCapacityH(tech,localDateStr(d))||8)*100)+'%', background: dayLoadColor(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech)).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/(techDayCapacityH(tech,localDateStr(d))||8)) }"></div>
</div>
<span class="sb-day-load-label">{{ fmtDur(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d),tech)).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)) }}/{{ fmtDur(techDayCapacityH(tech,localDateStr(d))||8) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,395 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchTechnicians, loadTechTags, fetchJobsFast, fetchJobFull, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch'
import { TECH_COLORS } from 'src/config/erpnext'
import { serializeAssistants, normalizeStatus, parseWeeklySchedule } from 'src/composables/useHelpers'
import { useGpsTracking } from 'src/composables/useGpsTracking'
function nextWeekday () {
const d = new Date()
const day = d.getDay()
if (day === 6) d.setDate(d.getDate() + 2)
else if (day === 0) d.setDate(d.getDate() + 1)
return d.toISOString().slice(0, 10)
}
export const useDispatchStore = defineStore('dispatch', () => {
const technicians = ref([])
const jobs = ref([])
const allTags = ref([])
const loading = ref(false)
const erpStatus = ref('pending')
const jobVersion = ref(0) // Incremented on any job/queue mutation to bust caches
const { traccarDevices, pollGps, startGpsTracking, stopGpsTracking } = useGpsTracking(technicians)
function _mapJob (j) {
return {
id: j.ticket_id || j.name,
name: j.name,
subject: j.subject || 'Job sans titre',
address: j.address || 'Adresse inconnue',
coords: [j.longitude || 0, j.latitude || 0],
// Persisted ERPNext links — surfaced as clickable shortcuts in
// the RightPanel so the dispatcher can jump to the client's
// full record (or the Service Location in ERPNext) without
// leaving the dispatch board. The Mapbox marker still uses
// the cached coords above; for source-of-truth verification
// (geocode mismatch) the rep clicks through to /clients/:id.
customer: j.customer || null,
serviceLocation: j.service_location || null,
priority: j.priority || 'low',
duration: j.duration_h || 1,
status: j.status || 'open',
assignedTech: j.assigned_tech || null,
routeOrder: j.route_order || 0,
legDist: j.leg_distance || null,
legDur: j.leg_duration || null,
scheduledDate: j.scheduled_date || null,
endDate: j.end_date || null,
startTime: j.start_time || null,
assistants: (j.assistants || []).map(a => ({ techId: a.tech_id, techName: a.tech_name, duration: a.duration_h || 0, note: a.note || '', pinned: !!a.pinned })),
tags: (j.tags || []).map(t => t.tag),
tagsWithLevel: (j.tags || []).map(t => ({ tag: t.tag, level: t.level || 0, required: t.required || 0 })),
customer: j.customer || null,
serviceLocation: j.service_location || null,
sourceIssue: j.source_issue || null,
dependsOn: j.depends_on || null,
jobType: j.job_type || null,
legacyDept: j.legacy_dept || null, // département osTicket legacy → coloriage par type
legacyTicketId: j.legacy_ticket_id || null, // n° ticket legacy (affiché dans le panneau détail)
legacyActivationUrl: j.legacy_activation_url || null, // lien connect_ministra (activation STB TV)
legacyDetail: j.legacy_detail || null, // description/contenu du ticket legacy (1er message du fil)
parentJob: j.parent_job || null,
stepOrder: j.step_order || 0,
onOpenWebhook: j.on_open_webhook || null,
onCloseWebhook: j.on_close_webhook || null,
salesOrder: j.sales_order || null,
orderSource: j.order_source || 'Manual',
published: j.published === undefined ? true : !!j.published,
continuous: !!j.continuous, // Emergency: span weekends/off-days
// Recurrence fields
isRecurring: !!j.is_recurring,
recurrenceRule: j.recurrence_rule || null, // RRULE string
recurrenceEnd: j.recurrence_end || null, // YYYY-MM-DD or null = indefinite
pausePeriods: j.pause_periods ? (typeof j.pause_periods === 'string' ? JSON.parse(j.pause_periods) : j.pause_periods) : [],
templateId: j.template_id || null, // For materialized instances → parent template
}
}
function _mapTech (t, idx) {
const status = normalizeStatus(t.status)
return {
id: t.technician_id || t.name,
name: t.name,
fullName: t.full_name || t.name,
status,
active: status !== 'inactive' && status !== 'off',
group: t.tech_group || '',
resourceType: t.resource_type || 'human', // 'human' | 'material'
resourceCategory: t.resource_category || '', // 'Véhicule' | 'Outil' | 'Salle' | etc.
user: t.user || null,
colorIdx: idx % TECH_COLORS.length,
// Default departure point = Gigafibre HQ (1867 chemin de la Rivière,
// Sainte-Clotilde, QC). Used when a tech has no explicit longitude
// /latitude saved in ERPNext, so route optimization has a sensible
// starting point even before the dispatcher sets it.
coords: [t.longitude || -73.6756177, t.latitude || 45.1599145],
gpsCoords: null,
gpsSpeed: 0,
gpsTime: null,
gpsOnline: false,
traccarDeviceId: t.traccar_device_id || null,
phone: t.phone || '',
email: t.email || '',
absenceReason: t.absence_reason || '',
absenceFrom: t.absence_from || null,
absenceUntil: t.absence_until || null,
absenceStartTime: t.absence_start_time || null,
absenceEndTime: t.absence_end_time || null,
weeklySchedule: parseWeeklySchedule(t.weekly_schedule),
queue: [],
tags: (t.tags || []).map(tg => tg.tag),
tagsWithLevel: (t.tags || []).map(tg => ({ tag: tg.tag, level: tg.level || 0 })),
extraShifts: t.extra_shifts ? (typeof t.extra_shifts === 'string' ? JSON.parse(t.extra_shifts) : t.extra_shifts) : [],
}
}
function _rebuildQueues () {
technicians.value.forEach(tech => {
tech.queue = jobs.value.filter(j => j.assignedTech === tech.id).sort((a, b) => a.routeOrder - b.routeOrder)
tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
})
}
async function loadAll (dateRange = null) {
loading.value = true
erpStatus.value = 'pending'
const t0 = performance.now()
try {
// All 3 fetches in parallel — techs, tags, jobs
const [rawTechs, rawTags, rawJobs] = await Promise.all([
fetchTechnicians(),
fetchTags(),
fetchJobsFast([['status', 'in', ['open', 'assigned', 'in_progress']]]),
])
allTags.value = rawTags
technicians.value = rawTechs.map(_mapTech)
jobs.value = rawJobs.map(_mapJob)
_rebuildQueues()
erpStatus.value = 'ok'
// Background: load tech tags (child tables) without blocking render
loadTechTags(rawTechs.map(t => t.name)).then(fullDocs => {
for (const doc of fullDocs) {
const tech = technicians.value.find(t => t.name === doc.name)
if (tech && doc.tags?.length) {
tech.tags = doc.tags.map(tg => tg.tag)
tech.tagsWithLevel = doc.tags.map(tg => ({ tag: tg.tag, level: tg.level || 0 }))
}
}
// Tech tags loaded in background
}).catch(() => {})
} catch (e) {
console.error('[dispatch] loadAll failed:', e)
erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error'
} finally {
loading.value = false
}
}
// Load full doc with child tables for a specific job (on demand)
async function loadJobDetails (jobId) {
const job = jobs.value.find(j => j.id === jobId || j.name === jobId)
if (!job) return
try {
const full = await fetchJobFull(job.name)
const mapped = _mapJob(full)
Object.assign(job, mapped)
_rebuildQueues()
} catch {}
}
async function loadJobsForTech (techId) {
loading.value = true
try {
const raw = await fetchJobsFast([['assigned_tech', '=', techId]])
jobs.value = raw.map(_mapJob)
} finally {
loading.value = false
}
}
async function setJobStatus (jobId, status) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
const prevStatus = job.status
job.status = status
await updateJob(job.id, { status })
// Fire n8n webhooks on status transitions
_fireWebhookIfNeeded(job, prevStatus, status)
}
function _fireWebhookIfNeeded (job, prevStatus, newStatus) {
const prev = (prevStatus || '').toLowerCase()
const next = (newStatus || '').toLowerCase()
let url = null
if (next === 'assigned' && prev === 'open' && job.onOpenWebhook) {
url = job.onOpenWebhook
} else if (next === 'completed' && job.onCloseWebhook) {
url = job.onCloseWebhook
}
if (url) {
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: next === 'completed' ? 'job_closed' : 'job_opened',
job_name: job.name, job_subject: job.subject,
job_status: newStatus, job_type: job.jobType,
customer: job.customer, timestamp: new Date().toISOString(),
}),
}).catch(err => console.warn('[n8n webhook]', err))
}
}
async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
const tech = technicians.value.find(t => t.id === techId)
if (tech) {
job.assignedTech = techId
job.routeOrder = routeOrder
job.status = 'assigned'
if (scheduledDate !== undefined) job.scheduledDate = scheduledDate
tech.queue.splice(routeOrder, 0, job)
tech.queue.forEach((q, i) => { q.routeOrder = i })
}
const payload = { assigned_tech: techId, route_order: routeOrder, status: 'assigned' }
if (scheduledDate !== undefined) payload.scheduled_date = scheduledDate || ''
await updateJob(job.id, payload)
}
async function unassignJob (jobId) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
job.assignedTech = null
job.status = 'open'
try { await updateJob(job.name || job.id, { assigned_tech: null, status: 'open' }) } catch (_) {}
}
async function createJob (fields) {
const localId = 'WO-' + Date.now().toString(36).toUpperCase()
const job = _mapJob({
ticket_id: localId, name: localId,
subject: fields.subject || 'Nouveau travail',
address: fields.address || '',
longitude: fields.longitude || 0,
latitude: fields.latitude || 0,
duration_h: parseFloat(fields.duration_h) || 1,
priority: fields.priority || 'low',
status: fields.assigned_tech ? 'assigned' : 'open',
assigned_tech: fields.assigned_tech || null,
scheduled_date: fields.scheduled_date || (fields.assigned_tech ? nextWeekday() : null),
start_time: fields.start_time || null,
route_order: 0,
published: 0,
})
jobs.value.push(job)
if (fields.assigned_tech) {
const tech = technicians.value.find(t => t.id === fields.assigned_tech)
if (tech) { job.routeOrder = tech.queue.length; tech.queue.push(job) }
}
try {
const created = await apiCreateJob({
subject: job.subject, address: job.address,
longitude: job.coords?.[0] || '', latitude: job.coords?.[1] || '',
duration_h: job.duration, priority: job.priority, status: job.status,
assigned_tech: job.assignedTech || '', scheduled_date: job.scheduledDate || '',
start_time: job.startTime || '',
customer: fields.customer || '', service_location: fields.service_location || '',
source_issue: fields.source_issue || '', depends_on: fields.depends_on || '',
job_type: fields.job_type || '',
parent_job: fields.parent_job || '', step_order: fields.step_order || 0,
on_open_webhook: fields.on_open_webhook || '', on_close_webhook: fields.on_close_webhook || '',
})
if (created?.name) { job.id = created.name; job.name = created.name }
} catch (_) {}
return job
}
function publishJobsLocal (jobNames) {
for (const name of jobNames) {
const job = jobs.value.find(j => j.id === name || j.name === name)
if (job) job.published = true
}
}
async function setJobSchedule (jobId, scheduledDate, startTime) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
job.scheduledDate = scheduledDate || null
job.startTime = startTime !== undefined ? startTime : job.startTime
const payload = { scheduled_date: job.scheduledDate || '' }
if (startTime !== undefined) payload.start_time = startTime || ''
try { await updateJob(job.name || job.id, payload) } catch (_) {}
}
async function updateJobCoords (jobId, lng, lat) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
job.coords = [lng, lat]
try { await updateJob(job.name || job.id, { longitude: lng, latitude: lat }) } catch (_) {}
}
async function addAssistant (jobId, techId) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
if (job.assignedTech === techId) return
if (job.assistants.some(a => a.techId === techId)) return
const tech = technicians.value.find(t => t.id === techId)
const entry = { techId, techName: tech?.fullName || techId, duration: job.duration, note: '', pinned: false }
job.assistants = [...job.assistants, entry]
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }) } catch (_) {}
}
async function removeAssistant (jobId, techId) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
job.assistants = job.assistants.filter(a => a.techId !== techId)
const tech = technicians.value.find(t => t.id === techId)
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }) } catch (_) {}
}
async function reorderTechQueue (techId, fromIdx, toIdx) {
const tech = technicians.value.find(t => t.id === techId)
if (!tech) return
const [moved] = tech.queue.splice(fromIdx, 1)
tech.queue.splice(toIdx, 0, moved)
tech.queue.forEach((q, i) => { q.routeOrder = i })
await Promise.all(tech.queue.map((q, i) => updateJob(q.id, { route_order: i })))
}
function smartAssign (jobId, newTechId, dateStr) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
if (job.assistants.some(a => a.techId === newTechId)) {
job.assistants = job.assistants.filter(a => a.techId !== newTechId)
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
}
assignJobToTech(jobId, newTechId, technicians.value.find(t => t.id === newTechId)?.queue.length || 0, dateStr)
_rebuildAssistJobs()
}
function fullUnassign (jobId) {
jobVersion.value++
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
if (job.assistants.length) { job.assistants = []; updateJob(job.name || job.id, { assistants: [] }).catch(() => {}) }
unassignJob(jobId)
_rebuildAssistJobs()
}
function _rebuildAssistJobs () {
technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) })
}
async function createTechnician (fields) {
const maxNum = technicians.value.reduce((max, t) => {
const m = (t.id || '').match(/TECH-(\d+)/)
return m ? Math.max(max, parseInt(m[1])) : max
}, 0)
fields.technician_id = 'TECH-' + (maxNum + 1)
const doc = await apiCreateTech(fields)
const tech = _mapTech(doc, technicians.value.length)
technicians.value.push(tech)
return tech
}
async function deleteTechnician (techId) {
const tech = technicians.value.find(t => t.id === techId)
if (!tech) return
await apiDeleteTech(tech.name)
technicians.value = technicians.value.filter(t => t.id !== techId)
}
const startGpsPolling = startGpsTracking
const stopGpsPolling = stopGpsTracking
return {
technicians, jobs, allTags, loading, erpStatus, jobVersion, traccarDevices,
loadAll, loadJobsForTech, loadJobDetails,
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
smartAssign, fullUnassign, publishJobsLocal,
pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling,
createTechnician, deleteTechnician,
}
})