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:
parent
da9c918fa9
commit
daeb86008b
|
|
@ -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>
|
|
||||||
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: 6AM–6PM. Week view: 7AM–8PM.
|
|
||||||
const H_START = computed(() => currentView.value === 'day' ? 6 : 7)
|
|
||||||
const H_END = computed(() => currentView.value === 'day' ? 18 : 20)
|
|
||||||
|
|
||||||
// ── Route cache ────────────────────────────────────────────────────────────
|
|
||||||
const routeLegs = ref({})
|
|
||||||
const routeGeometry = ref({})
|
|
||||||
|
|
||||||
// ── Ghost occurrences from recurring templates ─────────────────────────────
|
|
||||||
function ghostOccurrencesForDate (tech, dateStr) {
|
|
||||||
const templates = tech.queue.filter(j => j.isRecurring && j.recurrenceRule)
|
|
||||||
if (!templates.length) return []
|
|
||||||
const ghosts = []
|
|
||||||
for (const tpl of templates) {
|
|
||||||
const rangeEnd = tpl.recurrenceEnd || localDateStr((() => { const d = new Date(); d.setDate(d.getDate() + 90); return d })())
|
|
||||||
const dates = expandRRule(tpl.recurrenceRule, tpl.scheduledDate, dateStr, dateStr, tpl.pausePeriods || [])
|
|
||||||
if (!dates.includes(dateStr)) continue
|
|
||||||
// Skip if dateStr is the template's own scheduledDate (already rendered as real job)
|
|
||||||
if (dateStr === tpl.scheduledDate) continue
|
|
||||||
// Skip if a materialized instance already exists for this date
|
|
||||||
const hasMaterialized = tech.queue.some(j => j.templateId === tpl.id && j.scheduledDate === dateStr)
|
|
||||||
if (hasMaterialized) continue
|
|
||||||
// Skip tech off-days (unless continuous)
|
|
||||||
if (!tpl.continuous && !techDaySchedule(tech, dateStr)) continue
|
|
||||||
ghosts.push({
|
|
||||||
...tpl,
|
|
||||||
id: `ghost-${tpl.id}-${dateStr}`,
|
|
||||||
_realId: tpl.id,
|
|
||||||
scheduledDate: dateStr,
|
|
||||||
endDate: null,
|
|
||||||
_isGhost: true,
|
|
||||||
_templateJob: tpl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return ghosts
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Parent start position cache ────────────────────────────────────────────
|
|
||||||
let _parentStartCache = {}
|
|
||||||
|
|
||||||
function getParentStartH (job) {
|
|
||||||
if (!store.technicians.length) return job.startHour ?? 8
|
|
||||||
const key = `${job.assignedTech}||${job.id}`
|
|
||||||
if (_parentStartCache[key] !== undefined) return _parentStartCache[key]
|
|
||||||
const leadTech = store.technicians.find(t => t.id === job.assignedTech)
|
|
||||||
if (!leadTech) return job.startHour ?? 8
|
|
||||||
const dayStr = localDateStr(periodStart.value)
|
|
||||||
const leadJobs = sortJobsByTime(leadTech.queue.filter(j => getJobDate(j.id) === dayStr))
|
|
||||||
const cacheKey = `${leadTech.id}||${dayStr}`
|
|
||||||
const legMins = routeLegs.value[cacheKey]
|
|
||||||
const hasHome = !!(leadTech.coords?.[0] && leadTech.coords?.[1])
|
|
||||||
const _parentSched = techDaySchedule(leadTech, dayStr)
|
|
||||||
let cursor = _parentSched ? _parentSched.startH : 8, result = job.startHour ?? cursor
|
|
||||||
leadJobs.forEach((j, idx) => {
|
|
||||||
const showTravel = idx > 0 || (idx === 0 && hasHome)
|
|
||||||
if (showTravel) {
|
|
||||||
const legIdx = hasHome ? idx : idx - 1
|
|
||||||
const routeMin = legMins?.[legIdx]
|
|
||||||
cursor += (routeMin != null ? routeMin : (parseFloat(j.legDur) > 0 ? parseFloat(j.legDur) : 20)) / 60
|
|
||||||
}
|
|
||||||
const pinnedH = j.startTime ? timeToH(j.startTime) : null
|
|
||||||
const startH = pinnedH ?? cursor
|
|
||||||
if (j.id === job.id) result = startH
|
|
||||||
cursor = startH + (parseFloat(j.duration) || 1)
|
|
||||||
})
|
|
||||||
_parentStartCache[key] = result
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── All jobs for a tech on a date (primary + assists) ──────────────────────
|
|
||||||
function techAllJobsForDate (tech, dateStr) {
|
|
||||||
_parentStartCache = {}
|
|
||||||
const primary = tech.queue.filter(j => jobSpansDate(j, dateStr, tech))
|
|
||||||
const assists = (tech.assistJobs || [])
|
|
||||||
.filter(j => jobSpansDate(j, dateStr, tech))
|
|
||||||
.map(j => {
|
|
||||||
const a = j.assistants.find(x => x.techId === tech.id)
|
|
||||||
const parentH = getParentStartH(j)
|
|
||||||
return {
|
|
||||||
...j,
|
|
||||||
duration: a?.duration || j.duration,
|
|
||||||
startTime: hToTime(parentH),
|
|
||||||
startHour: parentH,
|
|
||||||
_isAssist: true,
|
|
||||||
_assistPinned: !!a?.pinned,
|
|
||||||
_assistNote: a?.note || '',
|
|
||||||
_parentJob: j,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const ghosts = ghostOccurrencesForDate(tech, dateStr)
|
|
||||||
return sortJobsByTime([...primary, ...assists, ...ghosts])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Absence / schedule-off segments for a tech on a given date ───────────────
|
|
||||||
function absenceSegmentsForDate (tech, dateStr) {
|
|
||||||
const segs = []
|
|
||||||
|
|
||||||
// 1. Explicit absence (vacation, sick, etc.)
|
|
||||||
if (tech.status === 'off' && tech.absenceFrom) {
|
|
||||||
const from = tech.absenceFrom
|
|
||||||
const until = tech.absenceUntil || from
|
|
||||||
if (dateStr >= from && dateStr <= until) {
|
|
||||||
const startH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : H_START.value
|
|
||||||
const endH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : H_END.value
|
|
||||||
const reasonObj = ABSENCE_REASONS.find(r => r.value === tech.absenceReason) || { label: 'Absent', icon: '⏸' }
|
|
||||||
const left = (startH - H_START.value) * pxPerHr.value
|
|
||||||
const width = (endH - startH) * pxPerHr.value
|
|
||||||
segs.push({
|
|
||||||
type: 'absence', startH, endH,
|
|
||||||
reason: tech.absenceReason, reasonLabel: reasonObj.label, reasonIcon: reasonObj.icon,
|
|
||||||
from: tech.absenceFrom, until: tech.absenceUntil, techId: tech.id,
|
|
||||||
style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' },
|
|
||||||
job: { id: `absence-${tech.id}-${dateStr}` },
|
|
||||||
})
|
|
||||||
return segs // absence covers the day — skip schedule check
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Weekly schedule off-day (regular day off like Fridays for 4×10 schedule)
|
|
||||||
// Marked as _isDayOff so TimelineRow can hide it when planning mode is off
|
|
||||||
const daySched = techDaySchedule(tech, dateStr)
|
|
||||||
if (!daySched) {
|
|
||||||
const left = 0
|
|
||||||
const width = (H_END.value - H_START.value) * pxPerHr.value
|
|
||||||
segs.push({
|
|
||||||
type: 'absence', startH: H_START.value, endH: H_END.value,
|
|
||||||
reason: 'day_off', reasonLabel: 'Jour de repos', reasonIcon: '📅',
|
|
||||||
_isDayOff: true,
|
|
||||||
from: null, until: null, techId: tech.id,
|
|
||||||
style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' },
|
|
||||||
job: { id: `schedoff-${tech.id}-${dateStr}` },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return segs
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Day view: schedule blocks with pinned anchors + auto-flow ──────────────
|
|
||||||
function techDayJobsWithTravel (tech) {
|
|
||||||
_checkCacheInvalidation()
|
|
||||||
const dayStr = localDateStr(periodStart.value)
|
|
||||||
const segKey = `${tech.id}||${dayStr}||${tech.queue.length}||${(tech.assistJobs||[]).length}`
|
|
||||||
if (_daySegmentsCache.has(segKey)) return _daySegmentsCache.get(segKey)
|
|
||||||
const cacheKey = `${tech.id}||${dayStr}`
|
|
||||||
const legMins = routeLegs.value[cacheKey]
|
|
||||||
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1])
|
|
||||||
const allJobs = techAllJobsForDate(tech, dayStr)
|
|
||||||
|
|
||||||
const flowEntries = []
|
|
||||||
const floatingEntries = []
|
|
||||||
allJobs.forEach(job => {
|
|
||||||
const isAssist = !!job._isAssist
|
|
||||||
const dur = parseFloat(job.duration) || 1
|
|
||||||
const isPinned = isAssist ? !!job._assistPinned : !!getJobTime(job.id)
|
|
||||||
const pinH = isAssist ? job.startHour : (getJobTime(job.id) ? timeToH(getJobTime(job.id)) : null)
|
|
||||||
const entry = { job, dur, isAssist, isPinned, pinH }
|
|
||||||
if (isAssist && !job._assistPinned) floatingEntries.push(entry)
|
|
||||||
else flowEntries.push(entry)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Absence blocks act as occupied time (prevent auto-placing jobs there)
|
|
||||||
const absSegs = absenceSegmentsForDate(tech, dayStr)
|
|
||||||
const absOccupied = absSegs.map(s => ({ start: s.startH, end: s.endH }))
|
|
||||||
|
|
||||||
const pinnedAnchors = flowEntries.filter(e => e.isPinned).map(e => ({ start: e.pinH, end: e.pinH + e.dur }))
|
|
||||||
const placed = []
|
|
||||||
const occupied = [...pinnedAnchors.map(a => ({ ...a })), ...absOccupied]
|
|
||||||
const sortedFlow = [...flowEntries].sort((a, b) => {
|
|
||||||
if (a.isPinned && b.isPinned) return a.pinH - b.pinH
|
|
||||||
if (a.isPinned) return -1
|
|
||||||
if (b.isPinned) return 1
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
sortedFlow.filter(e => e.isPinned).forEach(e => placed.push({ entry: e, startH: e.pinH }))
|
|
||||||
|
|
||||||
const daySched = techDaySchedule(tech, dayStr)
|
|
||||||
let cursor = daySched ? daySched.startH : 8, flowIdx = 0
|
|
||||||
sortedFlow.filter(e => !e.isPinned).forEach(e => {
|
|
||||||
const legIdx = hasHome ? flowIdx : flowIdx - 1
|
|
||||||
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
|
|
||||||
const travelH = (routeMin != null ? routeMin : (parseFloat(e.job.legDur) > 0 ? parseFloat(e.job.legDur) : 20)) / 60
|
|
||||||
let startH = cursor + (flowIdx > 0 || hasHome ? travelH : 0)
|
|
||||||
let safe = false
|
|
||||||
while (!safe) {
|
|
||||||
const endH = startH + e.dur
|
|
||||||
const overlap = occupied.find(o => startH < o.end && endH > o.start)
|
|
||||||
if (overlap) startH = overlap.end + travelH
|
|
||||||
else safe = true
|
|
||||||
}
|
|
||||||
placed.push({ entry: e, startH })
|
|
||||||
occupied.push({ start: startH, end: startH + e.dur })
|
|
||||||
cursor = startH + e.dur
|
|
||||||
flowIdx++
|
|
||||||
})
|
|
||||||
|
|
||||||
// Inject absence blocks as pseudo-placed entries so travel chains around them
|
|
||||||
absSegs.forEach(s => {
|
|
||||||
placed.push({ entry: { job: s.job, dur: s.endH - s.startH, isAssist: false, isPinned: true, pinH: s.startH, _isAbsence: true, _absSeg: s }, startH: s.startH })
|
|
||||||
})
|
|
||||||
|
|
||||||
placed.sort((a, b) => a.startH - b.startH)
|
|
||||||
|
|
||||||
const result = []
|
|
||||||
|
|
||||||
// Shift availability background block (renders behind jobs)
|
|
||||||
if (daySched) {
|
|
||||||
const shiftLeft = (daySched.startH - H_START.value) * pxPerHr.value
|
|
||||||
const shiftWidth = (daySched.endH - daySched.startH) * pxPerHr.value
|
|
||||||
result.push({
|
|
||||||
type: 'shift', startH: daySched.startH, endH: daySched.endH,
|
|
||||||
label: `${daySched.start} – ${daySched.end}`,
|
|
||||||
style: { left: shiftLeft + 'px', width: Math.max(18, shiftWidth) + 'px', top: '0', bottom: '0', position: 'absolute' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extra shifts (on-call, garde) from tech.extraShifts
|
|
||||||
const extras = (tech.extraShifts || []).filter(s => {
|
|
||||||
if (!s.rrule || !s.startTime || !s.endTime) return false
|
|
||||||
const dates = expandRRule(s.rrule, s.from || tech.scheduledDate || dayStr, dayStr, dayStr, [])
|
|
||||||
return dates.includes(dayStr)
|
|
||||||
})
|
|
||||||
extras.forEach(s => {
|
|
||||||
const sH = timeToH(s.startTime), eH = timeToH(s.endTime)
|
|
||||||
result.push({
|
|
||||||
type: 'shift', startH: sH, endH: eH,
|
|
||||||
label: `${s.label || 'Garde'}: ${s.startTime} – ${s.endTime}`,
|
|
||||||
isOnCall: true,
|
|
||||||
style: { left: (sH - H_START.value) * pxPerHr.value + 'px', width: Math.max(18, (eH - sH) * pxPerHr.value) + 'px', top: '0', bottom: '0', position: 'absolute' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
let prevEndH = null
|
|
||||||
let legCounter = 0
|
|
||||||
placed.forEach((p) => {
|
|
||||||
const { entry, startH } = p
|
|
||||||
const { job, dur, isAssist, isPinned, _isAbsence, _absSeg } = entry
|
|
||||||
|
|
||||||
// Travel gap between previous block end and this block start
|
|
||||||
const travelStart = prevEndH ?? (hasHome ? 8 : null)
|
|
||||||
if (travelStart != null && startH > travelStart + 0.01 && !_isAbsence) {
|
|
||||||
const gapH = startH - travelStart
|
|
||||||
const realJob = isAssist ? job._parentJob : job
|
|
||||||
const legIdx = hasHome ? legCounter : legCounter - 1
|
|
||||||
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
|
|
||||||
const fromRoute = routeMin != null
|
|
||||||
result.push({
|
|
||||||
type: 'travel', job: realJob, travelMin: fromRoute ? routeMin : Math.round(gapH * 60), fromRoute, isAssist: false,
|
|
||||||
style: { left: (travelStart - H_START.value) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' },
|
|
||||||
color: jobColorFn(realJob),
|
|
||||||
})
|
|
||||||
legCounter++
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isAbsence) {
|
|
||||||
// Render as absence block
|
|
||||||
result.push(_absSeg)
|
|
||||||
} else {
|
|
||||||
const realJob = isAssist ? job._parentJob : job
|
|
||||||
const jLeft = (startH - H_START.value) * pxPerHr.value
|
|
||||||
const jWidth = Math.max(18, dur * pxPerHr.value)
|
|
||||||
result.push({
|
|
||||||
type: isAssist ? 'assist' : 'job', job: realJob,
|
|
||||||
pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist,
|
|
||||||
assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null,
|
|
||||||
assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null,
|
|
||||||
_isGhost: !!job._isGhost, _templateJob: job._templateJob || null,
|
|
||||||
style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
prevEndH = startH + dur
|
|
||||||
})
|
|
||||||
|
|
||||||
floatingEntries.forEach(entry => {
|
|
||||||
const { job, dur } = entry
|
|
||||||
const startH = job.startHour ?? 8
|
|
||||||
result.push({
|
|
||||||
type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true,
|
|
||||||
assistPinned: false, assistDur: dur, assistNote: job._assistNote, assistTechId: tech.id,
|
|
||||||
style: { left: (startH - H_START.value) * pxPerHr.value + 'px', width: Math.max(18, dur * pxPerHr.value) + 'px', top: '52%', bottom: '4px', position: 'absolute' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
_daySegmentsCache.set(segKey, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Week view helpers ──────────────────────────────────────────────────────
|
|
||||||
function techBookingsByDay (tech) {
|
|
||||||
return dayColumns.value.map(d => {
|
|
||||||
const ds = localDateStr(d)
|
|
||||||
const primary = tech.queue.filter(j => jobSpansDate(j, ds, tech))
|
|
||||||
const assists = (tech.assistJobs || [])
|
|
||||||
.filter(j => jobSpansDate(j, ds, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned)
|
|
||||||
.map(j => ({ ...j, _isAssistChip: true, _assistDur: j.assistants.find(a => a.techId === tech.id)?.duration || j.duration }))
|
|
||||||
const ghosts = ghostOccurrencesForDate(tech, ds)
|
|
||||||
const absSegs = absenceSegmentsForDate(tech, ds)
|
|
||||||
return { day: d, dateStr: ds, jobs: [...primary, ...assists, ...ghosts], absent: absSegs.length > 0, absenceInfo: absSegs[0] || null }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Memoization caches (cleared on reactive dependency changes) ──────────
|
|
||||||
const _periodLoadCache = new Map()
|
|
||||||
const _periodCapCache = new Map()
|
|
||||||
const _daySegmentsCache = new Map()
|
|
||||||
|
|
||||||
// Invalidate caches when period/view changes
|
|
||||||
let _lastCacheKey = ''
|
|
||||||
function _checkCacheInvalidation () {
|
|
||||||
const key = `${currentView.value}||${periodStart.value}||${dayColumns.value.length}||${store.jobs.length}||${store.jobVersion}`
|
|
||||||
if (key !== _lastCacheKey) {
|
|
||||||
_lastCacheKey = key
|
|
||||||
_periodLoadCache.clear()
|
|
||||||
_periodCapCache.clear()
|
|
||||||
_daySegmentsCache.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function periodLoadH (tech) {
|
|
||||||
_checkCacheInvalidation()
|
|
||||||
if (_periodLoadCache.has(tech.id)) return _periodLoadCache.get(tech.id)
|
|
||||||
const dateSet = new Set(dayColumns.value.map(d => localDateStr(d)))
|
|
||||||
let total = tech.queue.reduce((sum, j) => {
|
|
||||||
const ds = getJobDate(j.id)
|
|
||||||
return ds && dateSet.has(ds) ? sum + (parseFloat(j.duration) || 0) : sum
|
|
||||||
}, 0)
|
|
||||||
;(tech.assistJobs || []).forEach(j => {
|
|
||||||
const ds = getJobDate(j.id)
|
|
||||||
if (ds && dateSet.has(ds)) {
|
|
||||||
const a = j.assistants.find(x => x.techId === tech.id)
|
|
||||||
if (a?.pinned) total += parseFloat(a?.duration || j.duration) || 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// Include absence hours as occupied time
|
|
||||||
dayColumns.value.forEach(d => {
|
|
||||||
const ds = localDateStr(d)
|
|
||||||
const absSegs = absenceSegmentsForDate(tech, ds)
|
|
||||||
absSegs.forEach(s => { total += s.endH - s.startH })
|
|
||||||
})
|
|
||||||
_periodLoadCache.set(tech.id, total)
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-tech capacity for the visible period (for utilization bar denominator)
|
|
||||||
function techPeriodCapacityH (tech) {
|
|
||||||
_checkCacheInvalidation()
|
|
||||||
if (_periodCapCache.has(tech.id)) return _periodCapCache.get(tech.id)
|
|
||||||
let total = 0
|
|
||||||
dayColumns.value.forEach(d => {
|
|
||||||
total += techDayCapacityH(tech, localDateStr(d))
|
|
||||||
})
|
|
||||||
const result = total || 8 // fallback to avoid /0
|
|
||||||
_periodCapCache.set(tech.id, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-tech end hour for current day (for capacity line position)
|
|
||||||
function techDayEndH (tech) {
|
|
||||||
const dayStr = localDateStr(periodStart.value)
|
|
||||||
const sched = techDaySchedule(tech, dayStr)
|
|
||||||
return sched ? sched.endH : 16
|
|
||||||
}
|
|
||||||
|
|
||||||
function techsActiveOnDay (dateStr, resources) {
|
|
||||||
return resources.filter(tech =>
|
|
||||||
tech.queue.some(j => jobSpansDate(j, dateStr, tech)) ||
|
|
||||||
(tech.assistJobs || []).some(j => jobSpansDate(j, dateStr, tech) && j.assistants.find(a => a.techId === tech.id)?.pinned)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayJobCount (dateStr, resources) {
|
|
||||||
const jobIds = new Set()
|
|
||||||
resources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr, t)).forEach(j => jobIds.add(j.id)))
|
|
||||||
return jobIds.size
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
H_START, H_END, routeLegs, routeGeometry,
|
|
||||||
techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate, ghostOccurrencesForDate,
|
|
||||||
techBookingsByDay, periodLoadH, techPeriodCapacityH, techDayEndH,
|
|
||||||
techsActiveOnDay, dayJobCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
/**
|
|
||||||
* API d'assignation UNIFIÉE — chemin d'écriture UNIQUE (via targo-hub).
|
|
||||||
* -----------------------------------------------------------------------------
|
|
||||||
* CONVERGENCE Dispatch ⇄ Planification : l'assignation job→tech
|
|
||||||
* (`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 })
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
/**
|
|
||||||
* useAssignment — composable d'assignation UNIFIÉE (le SEUL point d'écriture
|
|
||||||
* job→tech, 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 }
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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">→</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 :
|
|
||||||
<b>📱 Mes tâches</b> (application mobile) et
|
|
||||||
<b>📅 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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue
Block a user