OSS-BSS-Field-Dispatch/src/pages/DispatchPage.vue
louispaulb 1b0fc89304 Initial commit — OSS/BSS Field Dispatch app
Current state: custom CSS + vanilla Vue components
Architecture: modular with composables, provide/inject pattern
Ready for progressive migration to Quasar native components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:11:40 -04:00

1960 lines
95 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useAuthStore } from 'src/stores/auth'
import { useDispatchStore } from 'src/stores/dispatch'
import { TECH_COLORS, MAPBOX_TOKEN } from 'src/config/erpnext'
import { fetchOpenRequests, updateServiceRequestStatus } from 'src/api/service-request'
const auth = useAuthStore()
const store = useDispatchStore()
// ── UI state ────────────────────────────────────────────────────────────────
const currentView = ref(localStorage.getItem('dispatch-view') || 'schedule')
const sidebarCollapsed = ref(localStorage.getItem('dispatch-sidebar') === '1')
function toggleSidebar () {
sidebarCollapsed.value = !sidebarCollapsed.value
localStorage.setItem('dispatch-sidebar', sidebarCollapsed.value ? '1' : '0')
nextTick(() => { _measureSchedule(); if (currentView.value === 'map') map?.resize() })
}
const currentTheme = ref(localStorage.getItem('dispatch-theme') || 'dark')
const scheduleDate = ref(new Date())
const modalTicket = ref(null)
const tooltipTicket = ref(null)
const tooltipPos = ref({ x: 0, y: 0 })
const tooltipSize = ref({ w: 0, h: 0 })
let tooltipTimer = null
// ── Timeline scroll ref ───────────────────────────────────────────────────────
const schScroll = ref(null)
// ── Job labels AZ (identifiants visuels cohérents carte ↔ timeline) ─────────
const jobLabelMap = computed(() => {
const m = {}
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
store.jobs.forEach((job, i) => { m[job.id] = i < 26 ? alpha[i] : String(i + 1) })
return m
})
// ── Multi-sélection Ctrl+clic ─────────────────────────────────────────────────
const selectedJobIds = ref({}) // { [jobId]: true }
const selectedCount = computed(() => Object.keys(selectedJobIds.value).length)
function isJobSelected (id) { return !!selectedJobIds.value[id] }
function clearSelection () { selectedJobIds.value = {} }
function handleJobClick (e, jobId) {
e.stopPropagation()
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd+clic → toggle dans la multi-sélection
const s = { ...selectedJobIds.value }
if (s[jobId]) delete s[jobId]; else s[jobId] = true
selectedJobIds.value = s
} else {
// Clic simple → sélection unique (ou désélection si déjà seul sélectionné)
const s = selectedJobIds.value
selectedJobIds.value = (Object.keys(s).length === 1 && s[jobId]) ? {} : { [jobId]: true }
}
}
async function transferSelected (techId) {
const tech = store.technicians.find(t => t.id === techId)
if (!tech) return
pushHistory()
for (const jobId of Object.keys(selectedJobIds.value)) {
await store.assignJobToTech(jobId, techId, tech.queue.length)
}
await updateTechnicianRoute(tech)
clearSelection()
}
async function removeSelected () {
pushHistory()
for (const jobId of Object.keys(selectedJobIds.value)) {
const tech = store.technicians.find(t => t.queue.some(q => q.id === jobId))
const job = store.jobs.find(j => j.id === jobId)
if (tech && job) await removeJobFromTech(tech, job)
}
clearSelection()
}
// ── Undo history (Ctrl+Z / Cmd+Z) ────────────────────────────────────────────
const MAX_HISTORY = 30
const undoStack = []
function snapState () {
return {
jobs: store.jobs.map(j => ({ ...j })),
queues: store.technicians.reduce((acc, t) => { acc[t.id] = t.queue.map(q => q.id); return acc }, {}),
}
}
function pushHistory () {
undoStack.push(snapState())
if (undoStack.length > MAX_HISTORY) undoStack.shift()
}
async function undoLast () {
const snap = undoStack.pop()
if (!snap) return
// Restore job fields
snap.jobs.forEach(sj => {
const live = store.jobs.find(j => j.id === sj.id)
if (live) Object.assign(live, sj)
})
// Rebuild tech queues from stored ID order
store.technicians.forEach(tech => {
const ids = snap.queues[tech.id]
if (ids !== undefined)
tech.queue = ids.map(id => store.jobs.find(j => j.id === id)).filter(Boolean)
})
// Sync statuses to ERPNext (best-effort)
for (const sj of snap.jobs) {
store.setJobStatus(sj.id, sj.status).catch(() => {})
}
clearSelection()
for (const tech of store.technicians) await updateTechnicianRoute(tech)
drawAllMarkers()
}
// ── Rubber-band (lasso) selection on timeline ─────────────────────────────────
const lasso = ref(null) // { x1, y1, x2, y2 } in sch-inner absolute coordinates
const lassoStyle = computed(() => {
if (!lasso.value) return null
const { x1, y1, x2, y2 } = lasso.value
return {
left: Math.min(x1, x2) + 'px',
top: Math.min(y1, y2) + 'px',
width: Math.abs(x2 - x1) + 'px',
height: Math.abs(y2 - y1) + 'px',
}
})
function applyLassoSelection () {
if (!lasso.value) return
const { x1, y1, x2, y2 } = lasso.value
const lx1 = Math.min(x1, x2), lx2 = Math.max(x1, x2)
const ly1 = Math.min(y1, y2), ly2 = Math.max(y1, y2)
if (lx2 - lx1 < 4 && ly2 - ly1 < 4) return // ignore tiny clicks
const HEADER_H = 46, ROW_H = 72, COL_W = 140
const newSel = {}
scheduleRows.value.forEach((row, i) => {
const ry1 = HEADER_H + i * ROW_H
const ry2 = ry1 + ROW_H
if (ly2 < ry1 || ly1 > ry2) return
row.blocks.forEach(block => {
if (block.type !== 'job') return
const bx1 = COL_W + block.left
const bx2 = bx1 + block.width
if (lx2 < bx1 || lx1 > bx2) return
newSel[block.job.id] = true
})
})
selectedJobIds.value = newSel
}
function _lassoMouseMove (e) {
if (!lasso.value) return
const outer = schScroll.value
if (!outer) return
const rect = outer.getBoundingClientRect()
lasso.value = {
...lasso.value,
x2: e.clientX - rect.left + outer.scrollLeft,
y2: e.clientY - rect.top + outer.scrollTop,
}
applyLassoSelection()
}
function _lassoMouseUp () {
if (lasso.value) {
const { x1, y1, x2, y2 } = lasso.value
if (Math.abs(x2 - x1) < 4 && Math.abs(y2 - y1) < 4) {
clearSelection() // clic simple sur espace vide → désélectionner tout
} else {
applyLassoSelection()
}
lasso.value = null
}
document.removeEventListener('mousemove', _lassoMouseMove)
document.removeEventListener('mouseup', _lassoMouseUp)
}
function onSchMouseDown (e) {
if (e.button !== 0) return
if (e.target.closest('.sch-block') ||
e.target.closest('.sch-travel-label') ||
e.target.closest('.sch-tech-name') ||
e.target.closest('.sch-nav-bar')) return
const outer = schScroll.value
if (!outer) return
const rect = outer.getBoundingClientRect()
const x = e.clientX - rect.left + outer.scrollLeft
const y = e.clientY - rect.top + outer.scrollTop
lasso.value = { x1: x, y1: y, x2: x, y2: y }
e.preventDefault()
document.addEventListener('mousemove', _lassoMouseMove)
document.addEventListener('mouseup', _lassoMouseUp)
}
// ── Évaluations techniciens (localStorage) ───────────────────────────────────
const techRatings = ref(JSON.parse(localStorage.getItem('dispatch-tech-ratings') || '{}'))
// { [techId]: { score: 4.3, count: 17 } }
const ratingTech = ref(null) // techId dont le panneau de saisie est ouvert
const hoverRating = ref(0) // étoile survolée pendant la saisie
function setTechRating (techId, score) {
const r = techRatings.value[techId] || { score: 0, count: 0 }
const count = r.count + 1
const avg = parseFloat(((r.score * r.count + score) / count).toFixed(1))
techRatings.value = { ...techRatings.value, [techId]: { score: avg, count } }
localStorage.setItem('dispatch-tech-ratings', JSON.stringify(techRatings.value))
ratingTech.value = null
}
// ── Notifications techniciens ─────────────────────────────────────────────────
const notifyingTech = ref(null)
async function notifyTech (tech) {
notifyingTech.value = tech.id
const summary = tech.queue.map((j, i) => `${i + 1}. ${j.subject} (${j.duration}h)`).join(' | ')
try {
// Essaie n8n webhook si configuré
await fetch('/api/method/frappe.client.get_value?doctype=Dispatch+Settings&fieldname=n8n_webhook_base', {
credentials: 'include',
}).then(async r => {
const d = await r.json().catch(() => ({}))
const base = d.message?.n8n_webhook_base
if (base) {
await fetch(`${base}/notify-tech`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tech_name: tech.fullName, jobs: summary }),
}).catch(() => {})
}
}).catch(() => {})
} finally {
notifyingTech.value = null
}
}
// ── Mapbox ───────────────────────────────────────────────────────────────────
let map = null
function initMap () {
const mapboxgl = window.mapboxgl
if (!mapboxgl) return
mapboxgl.accessToken = MAPBOX_TOKEN
map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/dark-v11',
center: [-73.5673, 45.5017],
zoom: 11,
})
map.addControl(new mapboxgl.NavigationControl(), 'bottom-right')
map.on('load', () => {
setupMapLayers()
drawAllMarkers()
})
}
function setupMapLayers () {
if (!map) return
const helmetSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
const helmetUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(helmetSvg)
const img = new Image()
img.onload = () => {
if (!map.hasImage('tech-icon')) map.addImage('tech-icon', img)
}
img.src = helmetUrl
map.addSource('tickets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addSource('techs', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
const SEL = ['==', ['get', 'selected'], true]
map.addLayer({ id: 'tickets-glow', type: 'circle', source: 'tickets', paint: { 'circle-radius': 16, 'circle-color': ['get', 'color'], 'circle-opacity': 0.22, 'circle-blur': 0.8 } })
map.addLayer({ id: 'tickets-circle', type: 'circle', source: 'tickets', paint: { 'circle-radius': 10, 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': 'white' } })
map.addLayer({ id: 'tickets-sel-halo', type: 'circle', source: 'tickets', filter: SEL, paint: { 'circle-radius': 22, 'circle-color': '#ffffff', 'circle-opacity': 0.25, 'circle-blur': 0.6 } })
map.addLayer({ id: 'tickets-sel-ring', type: 'circle', source: 'tickets', filter: SEL, paint: { 'circle-radius': 13, 'circle-color': ['get', 'color'], 'circle-stroke-width': 3, 'circle-stroke-color': '#ffffff' } })
map.addLayer({ id: 'tickets-labels', type: 'symbol', source: 'tickets', layout: { 'text-field': ['get', 'label'], 'text-size': 10, 'text-anchor': 'center', 'text-allow-overlap': true, 'text-ignore-placement': true, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'] }, paint: { 'text-color': 'white' } })
map.addLayer({ id: 'techs-circle', type: 'symbol', source: 'techs', layout: { 'icon-image': 'tech-icon', 'icon-size': 1, 'icon-allow-overlap': true } })
;['tickets-circle', 'techs-circle'].forEach(layerId => {
map.on('click', layerId, e => {
const feat = e.features[0].properties
const coords = e.features[0].geometry.coordinates
new window.mapboxgl.Popup().setLngLat(coords).setHTML(
`<strong>${feat.id}</strong><br>${feat.subject || feat.name}<br><small>${feat.address || ''}</small>`
).addTo(map)
})
map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = '' })
})
}
function jobIsInView (job) {
// Unassigned jobs always visible
const tech = store.technicians.find(t => t.queue.some(q => q.id === job.id))
if (!tech) return true
const due = getJobDueDate(tech.id, job.id)
if (!due) return true // undated → treat as today (always show)
const viewStartStr = localDateStr(scheduleDate.value)
if (zoomView.value === 'day') return due === viewStartStr
const viewEnd = new Date(scheduleDate.value)
viewEnd.setDate(viewEnd.getDate() + weekDays.value - 1)
const viewEndStr = localDateStr(viewEnd)
return due >= viewStartStr && due <= viewEndStr
}
function drawAllMarkers () {
if (!map || !map.getSource('tickets')) return
const ticketFeatures = store.jobs.filter(jobIsInView).map(t => {
const color = jobColor(t)
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: t.coords },
properties: { id: t.id, subject: t.subject, address: t.address, color, assigned: !!t.assignedTech, label: jobLabelMap.value[t.id] || '', selected: !!selectedJobIds.value[t.id] },
}
}).filter(f => f.geometry.coordinates[0] !== 0)
const techFeatures = store.technicians.map(tech => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: tech.coords },
properties: { id: tech.id, name: tech.fullName },
})).filter(f => f.geometry.coordinates[0] !== 0)
map.getSource('tickets').setData({ type: 'FeatureCollection', features: ticketFeatures })
map.getSource('techs').setData({ type: 'FeatureCollection', features: techFeatures })
}
async function geocodeAddress (address) {
if (!address) return null
try {
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?limit=1&access_token=${MAPBOX_TOKEN}`
const res = await fetch(url)
const data = await res.json()
const feat = data.features?.[0]
if (feat) return feat.geometry.coordinates // [lng, lat]
} catch (_) {}
return null
}
function isValidCoords (c) {
return Array.isArray(c) && c.length === 2 && (c[0] !== 0 || c[1] !== 0)
}
async function updateTechnicianRoute (tech) {
if (!map) return
const sourceId = `route-${tech.id}`
const layerId = `route-layer-${tech.id}`
if (map.getLayer(layerId + '-outline')) map.removeLayer(layerId + '-outline')
if (map.getLayer(layerId)) map.removeLayer(layerId)
if (map.getSource(sourceId)) map.removeSource(sourceId)
tech.queue.forEach(t => { t.legDist = null; t.legDur = null })
// Ne tracer la route que pour le tech sélectionné
if (selectedTechId.value && tech.id !== selectedTechId.value) { drawAllMarkers(); return }
if (tech.queue.length === 0) { drawAllMarkers(); return }
// Geocode any job that still has [0,0] coords
for (const job of tech.queue) {
if (!isValidCoords(job.coords) && job.address) {
const c = await geocodeAddress(job.address)
if (c) job.coords = c
}
}
// Only route jobs with valid coords; record which queue indices are valid
const validJobs = tech.queue.filter(j => isValidCoords(j.coords))
if (!isValidCoords(tech.coords) || validJobs.length === 0) { drawAllMarkers(); return }
const waypoints = [tech.coords, ...validJobs.map(t => t.coords)]
const coordsStr = waypoints.map(c => c.join(',')).join(';')
try {
const res = await fetch(
`https://api.mapbox.com/directions/v5/mapbox/driving/${coordsStr}?geometries=geojson&overview=full&steps=false&access_token=${MAPBOX_TOKEN}`
)
const data = await res.json()
if (!data.routes?.[0]) { drawAllMarkers(); return }
const route = data.routes[0]
route.legs.forEach((leg, i) => {
if (validJobs[i]) {
validJobs[i].legDist = (leg.distance / 1000).toFixed(1)
validJobs[i].legDur = Math.round(leg.duration / 60)
}
})
const color = TECH_COLORS[tech.colorIdx]
const beforeId = map.getLayer('tickets-glow') ? 'tickets-glow' : undefined
map.addSource(sourceId, { type: 'geojson', data: { type: 'Feature', geometry: route.geometry } })
map.addLayer({ id: layerId + '-outline', type: 'line', source: sourceId, layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': color, 'line-width': 8, 'line-opacity': 0.25 } }, beforeId)
map.addLayer({ id: layerId, type: 'line', source: sourceId, layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': color, 'line-width': 3.5, 'line-opacity': 0.9 } }, beforeId)
} catch (e) {
console.error('Route error:', e)
}
drawAllMarkers()
}
// ── Theme ────────────────────────────────────────────────────────────────────
function applyTheme (theme) {
document.body.classList.toggle('light-mode', theme === 'light')
localStorage.setItem('dispatch-theme', theme)
}
function toggleTheme () {
currentTheme.value = currentTheme.value === 'dark' ? 'light' : 'dark'
applyTheme(currentTheme.value)
}
// ── View toggle ──────────────────────────────────────────────────────────────
function switchView (view) {
currentView.value = view
localStorage.setItem('dispatch-view', view)
if (view === 'map') nextTick(() => map?.resize())
if (view === 'schedule') nextTick(() => { _measureSchedule(); scrollToShiftStart() })
}
// Double-clic sur un jour de la vue semaine → zoom en vue jour sur cette date
function zoomToDay (date) {
if (!date) return
scheduleDate.value = new Date(date)
zoomView.value = 'day'
localStorage.setItem('dispatch-zoom', 'day')
nextTick(() => { _measureSchedule(); scrollToShiftStart() })
}
function scrollToShiftStart () {
nextTick(() => {
const el = schScroll.value
if (!el) return
// Vue semaine : tout tient à l'écran, pas de scroll horizontal
el.scrollLeft = zoomView.value === 'week' ? 0 : Math.max(0, SHIFT_START * pxPerMin.value - 40)
})
}
// ── Assignation des jobs à un jour — stockage par date absolue ISO ────────────
// clé : 'techId||jobId' → valeur : 'YYYY-MM-DD' (date absolue)
const jobDueDates = ref(JSON.parse(localStorage.getItem('dispatch-job-dates') || '{}'))
function _dayKey (techId, jobId) { return techId + '||' + jobId }
function getJobDueDate (techId, jobId) {
return jobDueDates.value[_dayKey(techId, jobId)] || null
}
// Retourne l'offset (0..weekDays-1) par rapport à scheduleDate pour la vue semaine
function getJobDay (techId, jobId) {
const due = getJobDueDate(techId, jobId)
if (!due) return 0
const viewStart = new Date(scheduleDate.value); viewStart.setHours(0, 0, 0, 0)
const d = new Date(due + 'T00:00:00')
const diff = Math.round((d - viewStart) / 86400000)
return Math.max(0, Math.min(diff, (weekDays.value || 5) - 1))
}
async function saveScheduledDate (jobId, dateStr) {
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''
try {
await fetch(`/api/resource/Issue/${encodeURIComponent(jobId)}`, {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
body: JSON.stringify({ expected_resolution_date: dateStr }),
})
} catch (_) {}
}
function localDateStr (d) {
// Format YYYY-MM-DD using LOCAL date parts to avoid UTC offset shifting the date
const yr = d.getFullYear()
const mo = String(d.getMonth() + 1).padStart(2, '0')
const da = String(d.getDate()).padStart(2, '0')
return `${yr}-${mo}-${da}`
}
function setJobDay (techId, jobId, day) {
const d = new Date(scheduleDate.value)
d.setHours(0, 0, 0, 0) // normalise à minuit local avant d'ajouter des jours
d.setDate(d.getDate() + day)
const dateStr = localDateStr(d)
jobDueDates.value = { ...jobDueDates.value, [_dayKey(techId, jobId)]: dateStr }
localStorage.setItem('dispatch-job-dates', JSON.stringify(jobDueDates.value))
saveScheduledDate(jobId, dateStr)
}
// Migre les anciennes données offset (dispatch-job-days) vers dates absolues (dispatch-job-dates)
// Les jobs sans date restent sans date (s'affichent sur tous les jours — comportement voulu)
function migrateJobDays () {
const oldData = JSON.parse(localStorage.getItem('dispatch-job-days') || '{}')
if (Object.keys(oldData).length === 0) return
// Ne migre que si le nouveau stockage est vide pour ne pas écraser des données existantes
if (Object.keys(jobDueDates.value).length === 0) {
const base = new Date(scheduleDate.value)
base.setHours(0, 0, 0, 0)
const newDates = {}
for (const [key, offset] of Object.entries(oldData)) {
const d = new Date(base)
d.setDate(d.getDate() + (offset || 0))
newDates[key] = localDateStr(d)
}
jobDueDates.value = newDates
localStorage.setItem('dispatch-job-dates', JSON.stringify(newDates))
}
localStorage.removeItem('dispatch-job-days')
}
// ── Demandes en attente (couche translucide sur le planning) ─────────────────
const SLOT_RANGES = {
morning: { start: 8 * 60, end: 12 * 60 },
afternoon: { start: 12 * 60, end: 17 * 60 },
evening: { start: 17 * 60, end: 20 * 60 },
flexible: { start: 8 * 60, end: 20 * 60 },
}
const SVC_ICONS = { internet: '🌐', tv: '📺', telephone: '📞', multi: '🔧' }
const showPendingLayer = ref(localStorage.getItem('dispatch-pending-layer') === '1')
const pendingReqs = ref([])
const pendingModal = ref(null) // demande ouverte dans le modal d'acceptation
const pendingLoading = ref(false)
const pendingAssignTech = ref('') // techId sélectionné dans le modal
// ── Field Dispatch UI state ───────────────────────────────────────────────────
const selectedTechId = ref(null)
const woSectionOpen = ref(true)
const techSectionOpen = ref(true)
const woTab = ref('unassigned')
const leftPanelW = ref(parseInt(localStorage.getItem('fd-left-w') || '260'))
const mapPanelW = ref(parseInt(localStorage.getItem('fd-map-w') || '320'))
const leftPanelOpen = ref(true)
const mapPanelOpen = ref(true)
// Couleurs par type de service (pins sur la carte + légende)
const SVC_COLORS = {
'Internet': '#3b82f6',
'Télévisión': '#a855f7',
'Téléphonie': '#10b981',
'Multi-service': '#f59e0b',
}
function jobColor (job) {
const svc = job.service_type || ''
if (SVC_COLORS[svc]) return SVC_COLORS[svc]
const s = (job.subject || '').toLowerCase()
if (s.includes('internet')) return '#3b82f6'
if (s.includes('tv') || s.includes('télév')) return '#a855f7'
if (s.includes('téléph')) return '#10b981'
if (s.includes('multi')) return '#f59e0b'
if (job.assignedTech) {
const t = store.technicians.find(x => x.id === job.assignedTech)
if (t) return TECH_COLORS[t.colorIdx]
}
return '#6b7280'
}
// Sélectionner un tech → affiche uniquement sa route sur la carte
function selectTech (techId) {
selectedTechId.value = (selectedTechId.value === techId) ? null : techId
store.technicians.forEach(t => {
const lid = `route-layer-${t.id}`
if (map?.getLayer(lid + '-outline')) map.removeLayer(lid + '-outline')
if (map?.getLayer(lid)) map.removeLayer(lid)
if (map?.getSource(`route-${t.id}`)) map.removeSource(`route-${t.id}`)
t.queue.forEach(j => { j.legDist = null; j.legDur = null })
})
if (selectedTechId.value) {
const t = store.technicians.find(x => x.id === selectedTechId.value)
if (t) updateTechnicianRoute(t)
}
drawAllMarkers()
}
function startResizeLeft (e) {
e.preventDefault()
const startX = e.clientX; const startW = leftPanelW.value
const move = ev => { leftPanelW.value = Math.max(180, Math.min(500, startW + ev.clientX - startX)); localStorage.setItem('fd-left-w', String(leftPanelW.value)) }
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up) }
document.addEventListener('mousemove', move); document.addEventListener('mouseup', up)
}
function startResizeMap (e) {
e.preventDefault()
const startX = e.clientX; const startW = mapPanelW.value
const move = ev => { mapPanelW.value = Math.max(200, Math.min(600, startW - (ev.clientX - startX))); localStorage.setItem('fd-map-w', String(mapPanelW.value)) }
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up) }
document.addEventListener('mousemove', move); document.addEventListener('mouseup', up)
}
// Statut techniciens (pour resource list + scheduleRows)
const stMap = {
'available': { cls: 'status-on-shift', label: 'Disponible' },
'en-route': { cls: 'status-enroute', label: 'En route' },
'busy': { cls: 'status-in-progress', label: 'En cours' },
'in progress': { cls: 'status-in-progress', label: 'En cours' },
'off': { cls: 'status-off', label: 'Hors shift' },
}
async function loadPendingReqs () {
pendingLoading.value = true
try { pendingReqs.value = await fetchOpenRequests() } catch (_) { pendingReqs.value = [] }
pendingLoading.value = false
}
function togglePendingLayer () {
showPendingLayer.value = !showPendingLayer.value
localStorage.setItem('dispatch-pending-layer', showPendingLayer.value ? '1' : '0')
if (showPendingLayer.value && pendingReqs.value.length === 0) loadPendingReqs()
}
// Retourne la liste des dates préférées d'une demande (format plat ou tableau)
function reqDates (req) {
if (Array.isArray(req.preferred_dates) && req.preferred_dates.length > 0)
return req.preferred_dates.filter(d => d.date).map((d, i) => ({
date: d.date,
slots: Array.isArray(d.time_slots) && d.time_slots.length ? d.time_slots : [d.time_slot || 'flexible'],
priority: i + 1,
}))
const out = []
for (let i = 1; i <= 3; i++) {
const d = req[`preferred_date_${i}`]
const s = req[`time_slot_${i}`]
if (d) out.push({ date: d, slots: [s || 'flexible'], priority: i })
}
return out
}
// Blocs positionnés sur le planning pour la vue courante
const pendingBlocks = computed(() => {
if (!showPendingLayer.value) return []
const blocks = []
const isWeek = zoomView.value === 'week'
const viewStart = new Date(scheduleDate.value)
viewStart.setHours(0, 0, 0, 0)
pendingReqs.value.forEach((req, ri) => {
const dates = reqDates(req)
dates.forEach(({ date, slots, priority }) => {
const d = new Date(date + 'T00:00:00')
d.setHours(0, 0, 0, 0)
let dayOffset = null
if (isWeek) {
const diff = Math.round((d - viewStart) / 86400000)
if (diff >= 0 && diff < weekDays.value) dayOffset = diff
} else {
if (d.toDateString() === viewStart.toDateString()) dayOffset = 0
}
if (dayOffset === null) return
slots.forEach(slot => {
const range = SLOT_RANGES[slot] || SLOT_RANGES.flexible
const dayBaseMin = isWeek ? dayOffset * 24 * 60 : 0
const left = (dayBaseMin + range.start) * pxPerMin.value
const width = Math.max((range.end - range.start) * pxPerMin.value, 4)
blocks.push({
key: `pr-${req.name || ri}-${date}-${slot}`,
left, width,
req,
slotLabel: { morning: 'Matin', afternoon: 'Après-midi', evening: 'Soir', flexible: 'Flexible' }[slot] || slot,
priority,
dateLabel: new Date(date + 'T12:00:00').toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' }),
icon: SVC_ICONS[req.service_type] || '🔧',
})
})
})
})
return blocks
})
// Drag depuis un bloc en attente → nouveau type 'pending'
function onPendingDragStart (e, req) {
dragState.value = { type: 'pending', req }
e.dataTransfer.effectAllowed = 'copy'
e.stopPropagation()
}
// Accepter et assigner la demande à un tech (depuis modal ou drop)
async function acceptPendingReq (req, techId) {
const tech = store.technicians.find(t => t.id === techId)
if (!tech) return
const reqId = req.name || req.ref || ('SR-' + Date.now().toString(36).toUpperCase())
// Résoudre les coordonnées GPS : utiliser celles stockées ou géocoder l'adresse
const address = req.address || ''
let coords = [0, 0]
if (isValidCoords(req.coordinates)) {
coords = req.coordinates
} else if (req.lng && req.lat) {
coords = [req.lng, req.lat]
} else if (address) {
coords = (await geocodeAddress(address)) || [0, 0]
}
// Créer un job local (fonctionne sans Frappe)
const job = {
id: reqId,
subject: `${SVC_ICONS[req.service_type] || '🔧'} ${req.problem_type || req.service_type}`,
address,
duration: '2',
priority: req.urgency === 'urgent' ? 'high' : 'medium',
status: 'open',
assignedTech: null,
coords,
}
if (!store.jobs.find(j => j.id === job.id)) store.jobs.push(job)
pushHistory()
await store.assignJobToTech(job.id, tech.id, tech.queue.length)
// Auto-placer sur le bon jour selon la 1re date préférée du client
const dates = reqDates(req)
let confirmedDateStr = localDateStr(new Date())
if (dates.length > 0) {
const prefDate = new Date(dates[0].date + 'T00:00:00')
prefDate.setHours(0, 0, 0, 0)
const viewStart = new Date(scheduleDate.value)
viewStart.setHours(0, 0, 0, 0)
const diff = Math.round((prefDate - viewStart) / 86400000)
const day = (diff >= 0 && diff < weekDays.value) ? diff : 0
setJobDay(tech.id, job.id, day)
confirmedDateStr = dates[0].date
} else {
// Pas de date préférée → assigne aujourd'hui
setJobDay(tech.id, job.id, 0)
}
await updateTechnicianRoute(tech)
// Persister le statut Confirmed dans ERPNext (empêche la réapparition dans les demandes)
try {
await updateServiceRequestStatus(reqId, 'Confirmed', confirmedDateStr)
} catch (_) {}
// Sauvegarder les dates non retenues en note/commentaire sur le billet ERPNext
if (dates.length > 1) {
const unaccepted = dates.slice(1)
.map(d => `${d.date}${d.slot ? ' — ' + d.slot : ''}`)
.join('\n')
const noteContent = `Dates proposées non retenues :\n${unaccepted}`
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''
try {
await fetch('/api/resource/Comment', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
body: JSON.stringify({
comment_type: 'Comment',
reference_doctype: 'Service Request',
reference_name: reqId,
content: noteContent,
}),
})
} catch (_) {}
}
// Retirer de la liste (supporte name Frappe ET ref localStorage)
const uid = req.name || req.ref
pendingReqs.value = pendingReqs.value.filter(r => (r.name || r.ref) !== uid)
pendingModal.value = null
pendingAssignTech.value = ''
}
// ── Schedule (Gantt) ─────────────────────────────────────────────────────────
const SHIFT_START = 8 * 60 // 08:00
const ZOOM_CONFIG = {
day: { pxPerMin: 2, totalMins: 24 * 60, tickMajor: 60, tickMinor: 30 },
week: { pxPerMin: 2 / 5, totalMins: 5 * 24 * 60, tickMajor: 24 * 60, tickMinor: 6 * 60 },
}
const zoomView = ref(localStorage.getItem('dispatch-zoom') || 'day')
// Nombre de jours affichés : 5 (lunven) ou 7 (lundim) si volet fermé
const weekDays = computed(() => (zoomView.value === 'week' && sidebarCollapsed.value) ? 7 : 5)
// En vue semaine, pxPerMin s'adapte pour afficher weekDays sans scroll horizontal
const scheduleW = ref(0)
let _resizeObs = null
function _measureSchedule () {
if (schScroll.value) scheduleW.value = schScroll.value.clientWidth
}
const pxPerMin = computed(() => {
if (zoomView.value === 'week' && scheduleW.value > 140)
return (scheduleW.value - 140) / (weekDays.value * 24 * 60)
return ZOOM_CONFIG[zoomView.value].pxPerMin
})
const totalMins = computed(() => zoomView.value === 'week' ? weekDays.value * 24 * 60 : ZOOM_CONFIG.day.totalMins)
const totalW = computed(() => totalMins.value * pxPerMin.value)
// Snap a date to the Monday of its week
function toMonday (date) {
const d = new Date(date)
const diff = (d.getDay() + 6) % 7 // 0=Mon … 6=Sun
d.setDate(d.getDate() - diff)
return d
}
const scheduleDateLabel = computed(() => {
if (zoomView.value === 'week') {
const end = new Date(scheduleDate.value)
end.setDate(end.getDate() + weekDays.value - 1)
return scheduleDate.value.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
+ ' ' + end.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })
}
return scheduleDate.value.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })
})
function prevDay () {
const d = new Date(scheduleDate.value)
d.setDate(d.getDate() - (zoomView.value === 'week' ? 7 : 1))
scheduleDate.value = d
}
function nextDay () {
const d = new Date(scheduleDate.value)
d.setDate(d.getDate() + (zoomView.value === 'week' ? 7 : 1))
scheduleDate.value = d
}
const rulerTicks = computed(() => {
const cfg = ZOOM_CONFIG[zoomView.value]
const now = new Date()
const isToday = scheduleDate.value.toDateString() === now.toDateString()
const ticks = []
for (let min = 0; min <= totalMins.value; min += cfg.tickMinor) {
const x = min * pxPerMin.value
const isMajor = min % cfg.tickMajor === 0
let label = null
let isCurrent = false
if (zoomView.value === 'week') {
if (isMajor) {
const d = new Date(scheduleDate.value)
d.setDate(d.getDate() + min / cfg.tickMajor)
label = d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric' })
isCurrent = d.toDateString() === now.toDateString()
ticks.push({ x, isMajor, label, isCurrent, date: new Date(d) })
continue
}
} else {
const h = Math.floor(min / 60)
if (isMajor) {
label = String(h).padStart(2, '0') + 'h00'
isCurrent = isToday && h === now.getHours()
}
}
ticks.push({ x, isMajor, label, isCurrent })
}
return ticks
})
const nowLineX = computed(() => {
const now = new Date()
if (zoomView.value === 'day') {
if (scheduleDate.value.toDateString() !== now.toDateString()) return -1
return (now.getHours() * 60 + now.getMinutes()) * pxPerMin.value
}
// week view: position within the N-day window starting at scheduleDate
const start = new Date(scheduleDate.value)
start.setHours(0, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + weekDays.value)
if (now < start || now >= end) return -1
return ((now - start) / 60000) * pxPerMin.value
})
const scheduleRows = computed(() => {
return store.technicians.map(tech => {
const color = TECH_COLORS[tech.colorIdx]
const st = stMap[(tech.status || '').toLowerCase()] || { cls: 'status-on-shift', label: tech.status || '—' }
const totalLoad = tech.queue.reduce((a, t) => a + (parseFloat(t.duration) || 0) * 60 + (parseInt(t.legDur) || 0), 0)
const blocks = []
if (zoomView.value === 'week') {
// Regroupe les jobs par jour assigné (04), empile depuis 8h00 de chaque jour
const byDay = {}
tech.queue.forEach((job, idx) => {
const d = getJobDay(tech.id, job.id)
;(byDay[d] = byDay[d] || []).push({ job, idx })
})
for (let dayNum = 0; dayNum < weekDays.value; dayNum++) {
let currentMin = dayNum * 24 * 60 + SHIFT_START
// Calcul de la date calendrier du jour
const dayDate = new Date(scheduleDate.value)
dayDate.setDate(dayDate.getDate() + dayNum)
const dayLabel = dayDate.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
const dueDateStr = localDateStr(dayDate)
for (const { job, idx } of (byDay[dayNum] || [])) {
const driveMin = parseInt(job.legDur) || 0
const workMin = Math.max((parseFloat(job.duration) || 1) * 60, 5)
if (driveMin > 0) {
blocks.push({ key: `${job.id}-drive`, type: 'travel', left: currentMin * pxPerMin.value, width: Math.max(driveMin * pxPerMin.value, 4), driveMin })
currentMin += driveMin
}
const sH = Math.floor(currentMin / 60) % 24, sM = currentMin % 60
const eMin = currentMin + workMin
const eH = Math.floor(eMin / 60) % 24, eM = eMin % 60
blocks.push({
key: `${job.id}-job`, type: 'job',
left: currentMin * pxPerMin.value, width: Math.max(workMin * pxPerMin.value, 20),
color, job, idx,
dueDate: dueDateStr,
timeRange: `${dayLabel}`,
timeHours: `${String(sH).padStart(2,'0')}h${String(sM).padStart(2,'0')}${String(eH).padStart(2,'0')}h${String(eM).padStart(2,'0')}`,
})
currentMin += workMin
}
}
} else {
// Vue jour : n'afficher que les jobs prévues pour la date sélectionnée
const todayStr = localDateStr(scheduleDate.value)
let currentMin = SHIFT_START
tech.queue.forEach((job, idx) => {
const due = getJobDueDate(tech.id, job.id)
// Pas de date assignée → visible sur tous les jours (non planifié)
if (due && due !== todayStr) return
const driveMin = parseInt(job.legDur) || 0
const workMin = Math.max((parseFloat(job.duration) || 1) * 60, 5)
if (driveMin > 0) {
blocks.push({ key: `${job.id}-drive`, type: 'travel', left: currentMin * pxPerMin.value, width: Math.max(driveMin * pxPerMin.value, 4), driveMin })
currentMin += driveMin
}
const sH = Math.floor(currentMin / 60), sM = currentMin % 60
const eMin = currentMin + workMin
const eH = Math.floor(eMin / 60), eM = eMin % 60
const timeHours = `${String(sH).padStart(2,'0')}h${String(sM).padStart(2,'0')}${String(eH).padStart(2,'0')}h${String(eM).padStart(2,'0')}`
blocks.push({
key: `${job.id}-job`, type: 'job',
left: currentMin * pxPerMin.value, width: Math.max(workMin * pxPerMin.value, 20),
color, job, idx,
dueDate: todayStr,
timeRange: timeHours,
timeHours,
})
currentMin += workMin
})
}
return { tech, color, st, loadH: (totalLoad / 60).toFixed(1), blocks }
})
})
// ── Drag & drop ──────────────────────────────────────────────────────────────
const dragState = ref(null) // { type: 'ticket'|'sort', jobId?, techId?, fromIdx? }
const dropTarget = ref(null) // { techId, insertIdx } — drives the visual indicator
function onTicketDragStart (e, job) {
if (job.status !== 'open') { e.preventDefault(); return }
dragState.value = { type: 'ticket', jobId: job.id }
e.dataTransfer.effectAllowed = 'move'
}
function onJobDragStart (e, tech, idx) {
dragState.value = { type: 'sort', techId: tech.id, fromIdx: idx }
e.dataTransfer.effectAllowed = 'move'
e.stopPropagation()
}
function onDragEnd () {
dragState.value = null
dropTarget.value = null
}
// Calculate where to insert based on mouse Y vs item midpoints
function calcInsertIdx (zoneEl, clientY, queueLen) {
const items = zoneEl.querySelectorAll('.job-item')
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect()
if (clientY < rect.top + rect.height / 2) return i
}
return queueLen
}
function onDragOverZone (e, tech) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const insertIdx = calcInsertIdx(e.currentTarget, e.clientY, tech.queue.length)
dropTarget.value = { techId: tech.id, insertIdx }
}
function onDragLeaveZone (e, tech) {
// Only clear when pointer truly leaves the zone (not entering a child element)
if (!e.currentTarget.contains(e.relatedTarget)) {
if (dropTarget.value?.techId === tech.id) dropTarget.value = null
}
}
async function onDropOnZone (e, tech) {
e.preventDefault()
if (!dragState.value) return
const ds = dragState.value
const insertIdx = dropTarget.value?.techId === tech.id
? dropTarget.value.insertIdx
: tech.queue.length
dragState.value = null
dropTarget.value = null
pushHistory()
if (ds.type === 'ticket') {
await store.assignJobToTech(ds.jobId, tech.id, insertIdx)
await updateTechnicianRoute(tech)
} else if (ds.type === 'sort') {
if (ds.techId === tech.id) {
await store.reorderTechQueue(tech.id, ds.fromIdx, insertIdx)
await updateTechnicianRoute(tech)
} else {
// Cross-tech: move job from one tech's queue to another
const srcTech = store.technicians.find(t => t.id === ds.techId)
const job = srcTech?.queue[ds.fromIdx]
if (job) {
await store.assignJobToTech(job.id, tech.id, insertIdx)
if (srcTech) await updateTechnicianRoute(srcTech)
await updateTechnicianRoute(tech)
}
}
}
}
// ── Timeline (schedule) drag & drop ──────────────────────────────────────────
const schDropTarget = ref(null) // { techId, insertIdx, xPos }
function onSchBlockDragStart (e, row, block) {
dragState.value = { type: 'sort', techId: row.tech.id, fromIdx: block.idx, jobId: block.job.id }
e.dataTransfer.effectAllowed = 'move'
e.stopPropagation()
}
function onSchDragOverRow (e, row) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const timelineEl = e.currentTarget.querySelector('.sch-timeline')
if (!timelineEl) return
const rect = timelineEl.getBoundingClientRect()
const x = e.clientX - rect.left
if (zoomView.value === 'week') {
// Snap to day column
const dayW = 24 * 60 * pxPerMin.value
const snapDay = Math.max(0, Math.min(weekDays.value - 1, Math.floor(x / dayW)))
schDropTarget.value = { techId: row.tech.id, insertIdx: -1, snapDay, xPos: snapDay * dayW, snapWidth: dayW }
return
}
const jobBlocks = row.blocks.filter(b => b.type === 'job')
let insertIdx = jobBlocks.length
for (let i = 0; i < jobBlocks.length; i++) {
if (x < jobBlocks[i].left + jobBlocks[i].width / 2) { insertIdx = i; break }
}
let xPos
if (jobBlocks.length === 0) {
xPos = SHIFT_START * pxPerMin.value
} else if (insertIdx === 0) {
xPos = jobBlocks[0].left - 4
} else if (insertIdx >= jobBlocks.length) {
const last = jobBlocks[jobBlocks.length - 1]
xPos = last.left + last.width + 4
} else {
xPos = jobBlocks[insertIdx - 1].left + jobBlocks[insertIdx - 1].width + 4
}
schDropTarget.value = { techId: row.tech.id, insertIdx, xPos }
}
function onSchDragLeaveRow (e, row) {
if (!e.currentTarget.contains(e.relatedTarget)) {
if (schDropTarget.value?.techId === row.tech.id) schDropTarget.value = null
}
}
async function onSchDropRow (e, row) {
e.preventDefault()
if (!dragState.value) return
const ds = dragState.value
const isWeek = zoomView.value === 'week'
const snapDay = schDropTarget.value?.snapDay ?? 0
const insertIdx = (!isWeek && schDropTarget.value?.techId === row.tech.id)
? schDropTarget.value.insertIdx
: row.tech.queue.length
dragState.value = null
schDropTarget.value = null
pushHistory()
if (ds.type === 'pending') {
await acceptPendingReq(ds.req, row.tech.id)
return
} else if (ds.type === 'ticket') {
await store.assignJobToTech(ds.jobId, row.tech.id, insertIdx)
if (isWeek) setJobDay(row.tech.id, ds.jobId, snapDay)
await updateTechnicianRoute(row.tech)
} else if (ds.type === 'sort') {
if (ds.techId === row.tech.id) {
if (!isWeek) await store.reorderTechQueue(row.tech.id, ds.fromIdx, insertIdx)
if (isWeek && ds.jobId) setJobDay(row.tech.id, ds.jobId, snapDay)
await updateTechnicianRoute(row.tech)
} else {
const srcTech = store.technicians.find(t => t.id === ds.techId)
const job = srcTech?.queue[ds.fromIdx]
if (job) {
await store.assignJobToTech(job.id, row.tech.id, insertIdx)
if (isWeek) setJobDay(row.tech.id, job.id, snapDay)
if (srcTech) await updateTechnicianRoute(srcTech)
await updateTechnicianRoute(row.tech)
}
}
}
}
async function removeJobFromTech (tech, job) {
pushHistory()
tech.queue = tech.queue.filter(q => q.id !== job.id)
tech.queue.forEach((q, i) => { q.routeOrder = i })
job.status = 'open'
job.assignedTech = null
await store.setJobStatus(job.id, 'open')
await updateTechnicianRoute(tech)
}
// ── Modal ────────────────────────────────────────────────────────────────────
function openModal (ticket) {
clearTooltip()
modalTicket.value = ticket
}
function closeModal () { modalTicket.value = null }
async function completeTicket (jobId) {
await store.setJobStatus(jobId, 'completed')
store.technicians.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
drawAllMarkers()
}
// ── Tooltip ──────────────────────────────────────────────────────────────────
function scheduleTooltip (ticket, e) {
clearTooltip()
tooltipTimer = setTimeout(() => showTooltip(ticket, e.clientX, e.clientY), 600)
}
function clearTooltip () {
clearTimeout(tooltipTimer)
tooltipTimer = null
tooltipTicket.value = null
}
function showTooltip (ticket, x, y) {
tooltipTicket.value = ticket
tooltipPos.value = { x, y }
nextTick(() => {
const el = document.getElementById('ticketTooltip')
if (!el) return
tooltipSize.value = { w: el.offsetWidth, h: el.offsetHeight }
})
}
const tooltipStyle = computed(() => {
const { x, y } = tooltipPos.value
const { w, h } = tooltipSize.value
return {
left: Math.min(x + 14, window.innerWidth - w - 8) + 'px',
top: Math.min(y + 14, window.innerHeight - h - 8) + 'px',
}
})
// ── Computed lists ────────────────────────────────────────────────────────────
const unassignedJobs = computed(() => store.jobs.filter(j => j.status === 'open'))
function prioClass (p) { return { high: 'priority-high', medium: 'priority-medium', low: 'priority-low' }[p] || '' }
function prioLabel (p) { return { high: 'Urgent', medium: 'Moyen', low: 'Faible' }[p] || p }
// ── ERP status label ──────────────────────────────────────────────────────────
const erpLabel = computed(() => ({
ok: 'ERPNext Connecté',
error: 'Erreur API',
session_expired:'Session expirée',
pending: 'ERPNext...',
}[store.erpStatus] || 'ERPNext...'))
/// ── Watch: redraw map when data or view period changes ────────────────────────
watch(() => [store.jobs, store.technicians], () => { drawAllMarkers() }, { deep: true })
watch(selectedJobIds, () => { drawAllMarkers() }, { deep: true })
watch([scheduleDate, zoomView, jobDueDates], () => { drawAllMarkers() }, { deep: false })
watch(zoomView, v => {
localStorage.setItem('dispatch-zoom', v)
if (v === 'week') scheduleDate.value = toMonday(scheduleDate.value)
nextTick(() => { _measureSchedule(); scrollToShiftStart() })
})
// ── Keyboard ─────────────────────────────────────────────────────────────────
function onKeyDown (e) {
if (e.key === 'Escape') { closeModal(); clearSelection() }
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undoLast() }
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedCount.value > 0 && !modalTicket.value) {
e.preventDefault(); removeSelected()
}
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
applyTheme(currentTheme.value)
document.addEventListener('keydown', onKeyDown)
await auth.checkSession()
await store.loadAll()
migrateJobDays()
if (showPendingLayer.value) loadPendingReqs()
nextTick(() => {
_measureSchedule()
_resizeObs = new ResizeObserver(_measureSchedule)
if (schScroll.value) _resizeObs.observe(schScroll.value)
scrollToShiftStart()
})
// Mapbox script is loaded via CDN in index.html; init after data is ready
if (window.mapboxgl) {
initMap()
} else {
const s = document.createElement('script')
s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'
s.onload = initMap
document.head.appendChild(s)
const l = document.createElement('link')
l.rel = 'stylesheet'
l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'
document.head.appendChild(l)
}
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('mousemove', _lassoMouseMove)
document.removeEventListener('mouseup', _lassoMouseUp)
if (_resizeObs) _resizeObs.disconnect()
clearTooltip()
if (map) map.remove()
})
</script>
<template>
<div class="fd-root" :class="{ 'light-mode': currentTheme === 'light' }">
<!-- TOP ACTION BAR -->
<nav class="fd-topbar">
<div class="fd-tb-left">
<div class="fd-logo">
<div class="fd-logo-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>
</div>
<span class="fd-logo-text">Dispatch</span>
</div>
<button class="fd-btn" @click="$router.push('/booking')">+ Demande</button>
<button class="fd-btn fd-btn-green" @click="$router.push('/contractor')">+ Technicien</button>
<button class="fd-btn fd-btn-pending" :class="{ active: showPendingLayer }" @click="togglePendingLayer">
📋 Demandes
<span v-if="pendingReqs.length" class="fd-badge-sm">{{ pendingReqs.length }}</span>
</button>
</div>
<div class="fd-tb-center">
<button class="fd-nav-btn" @click="prevDay"></button>
<span class="fd-date-label">{{ scheduleDateLabel }}</span>
<button class="fd-nav-btn" @click="nextDay"></button>
<div class="fd-zoom-toggle">
<button :class="{ active: zoomView === 'day' }" @click="zoomView = 'day'">Jour</button>
<button :class="{ active: zoomView === 'week' }" @click="zoomView = 'week'">Semaine</button>
</div>
</div>
<div class="fd-tb-right">
<div class="fd-erp-dot" :class="{ connected: store.erpStatus === 'ok' }"
:title="{ ok: 'ERPNext connecté', error: 'ERPNext hors ligne' }[store.erpStatus] || 'ERPNext...'"></div>
<button class="fd-btn-icon" @click="$router.push('/admin')" title="Paramètres">⚙️</button>
<button class="fd-btn-icon" @click="toggleTheme">{{ currentTheme === 'dark' ? '🌙' : '☀️' }}</button>
</div>
</nav>
<!-- ══════════════════════════ MAIN AREA (3 colonnes) ══════════════════ -->
<div class="fd-main">
<!-- ── LEFT : Techniciens + Work Orders ── -->
<div class="fd-left" :style="leftPanelOpen ? 'width:'+leftPanelW+'px;min-width:'+leftPanelW+'px' : 'width:36px;min-width:36px'">
<!-- Collapse toggle strip -->
<button class="fd-panel-toggle" @click="leftPanelOpen=!leftPanelOpen" :title="leftPanelOpen?'Réduire':'Ouvrir'">
{{ leftPanelOpen ? '◀' : '▶' }}
</button>
<!-- Panel body (hidden when collapsed) -->
<div v-show="leftPanelOpen" class="fd-left-body">
<!-- ── Section: Techniciens ── -->
<div class="fd-acc-hdr" @click="techSectionOpen=!techSectionOpen">
<span>Techniciens <span class="fd-badge-sm">{{ store.technicians.length }}</span></span>
<span class="fd-chevron">{{ techSectionOpen ? '▲' : '▼' }}</span>
</div>
<div v-show="techSectionOpen" class="fd-acc-body fd-res-list">
<div v-for="tech in store.technicians" :key="tech.id"
class="fd-res-row"
:class="{ 'fd-res-selected': selectedTechId === tech.id }"
@click="selectTech(tech.id)"
@dragover="onDragOverZone($event, tech)"
@dragleave="onDragLeaveZone($event, tech)"
@drop="onDropOnZone($event, tech)">
<div class="fd-res-sel-bar" :style="selectedTechId === tech.id ? 'background:' + TECH_COLORS[tech.colorIdx] : ''"></div>
<div class="fd-res-avatar" :style="'background:' + TECH_COLORS[tech.colorIdx]">
{{ tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0,2) }}
</div>
<div class="fd-res-info">
<div class="fd-res-name">{{ tech.fullName }}</div>
<div class="fd-res-meta">
<span class="status-badge" :class="(stMap[(tech.status||'').toLowerCase()] || stMap['available']).cls"
style="font-size:0.58rem;padding:0.05rem 0.3rem;border-radius:4px;">
{{ (stMap[(tech.status||'').toLowerCase()] || stMap['available']).label }}
</span>
<span class="fd-res-jobs">{{ tech.queue.reduce((a,j)=>a+(parseFloat(j.duration)||0),0).toFixed(1) }}h · {{ tech.queue.length }} job{{ tech.queue.length!==1?'s':'' }}</span>
</div>
<div class="fd-res-bar">
<div class="fd-res-bar-fill"
:style="{ width: Math.min(100, tech.queue.reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/8*100)+'%', background: TECH_COLORS[tech.colorIdx] }">
</div>
</div>
<div class="fd-res-rating" @click.stop="ratingTech = ratingTech===tech.id?null:tech.id">
<span class="stars-bg" style="font-size:0.65rem;">★★★★★</span>
<span class="stars-fg" style="font-size:0.65rem;" :style="{ width: ((techRatings[tech.id]?.score??5)/5*100)+'%' }">★★★★★</span>
<span style="font-size:0.62rem;color:var(--text-secondary);margin-left:0.2rem;">{{ techRatings[tech.id]?.score.toFixed(1) ?? '5.0' }}</span>
</div>
<div v-if="ratingTech===tech.id" class="rating-input" @click.stop>
<span v-for="n in 5" :key="n" class="star-btn" :class="{ active: n<=(hoverRating||0) }"
@mouseenter="hoverRating=n" @mouseleave="hoverRating=0" @click.stop="setTechRating(tech.id,n)">★</span>
</div>
</div>
<div v-if="dropTarget?.techId===tech.id" class="fd-drop-glow"></div>
</div>
</div>
<!-- Multi-selection bar -->
<div v-if="selectedCount > 0" class="fd-sel-toolbar">
<span class="fd-sel-count">{{ selectedCount }} sélectionné{{ selectedCount>1?'s':'' }}</span>
<select class="fd-sel-transfer" @change="transferSelected($event.target.value);$event.target.value=''">
<option value="">Transférer à…</option>
<option v-for="tech in store.technicians" :key="tech.id" :value="tech.id">{{ tech.fullName }}</option>
</select>
<button class="fd-sel-btn" @click="removeSelected" title="Retirer de la queue">✕</button>
<button class="fd-sel-btn" @click="clearSelection">↩</button>
</div>
<!-- ── Section: Work Orders ── -->
<div class="fd-acc-hdr fd-acc-hdr-wo">
<div class="fd-wo-tabs-inline" @click.stop>
<button class="fd-wo-tab" :class="{ active: woTab==='unassigned' }" @click="woTab='unassigned'">
Non assignés <span class="fd-badge-sm" :style="unassignedJobs.length?'background:#ef4444':''">{{ unassignedJobs.length }}</span>
</button>
<button class="fd-wo-tab" :class="{ active: woTab==='pending' }" @click="woTab='pending'">
Demandes <span v-if="pendingReqs.length" class="fd-badge-sm">{{ pendingReqs.length }}</span>
</button>
</div>
<span class="fd-chevron" @click="woSectionOpen=!woSectionOpen">{{ woSectionOpen ? '▲' : '▼' }}</span>
</div>
<div v-show="woSectionOpen" class="fd-acc-body fd-wo-list">
<template v-if="woTab==='unassigned'">
<div v-if="!unassignedJobs.length" class="fd-wo-empty">✓ Tous les tickets sont assignés</div>
<div v-for="job in unassignedJobs" :key="job.id"
class="fd-wo-card-v" :class="{ 'fd-wo-card-sel': isJobSelected(job.id) }"
draggable="true"
@dragstart="onTicketDragStart($event,job)"
@dragend="onDragEnd"
@click="handleJobClick($event,job.id)"
@dblclick="openModal(job)">
<div class="fd-wo-stripe" :style="'background:'+jobColor(job)"></div>
<div class="fd-wo-inner">
<div class="fd-wo-top">
<span class="job-label-badge">{{ jobLabelMap[job.id] }}</span>
<span class="ticket-priority" :class="prioClass(job.priority)">{{ prioLabel(job.priority) }}</span>
</div>
<div class="fd-wo-subject">{{ job.subject }}</div>
<div class="fd-wo-addr">📍 {{ job.address }}</div>
</div>
</div>
</template>
<template v-else>
<div v-if="!pendingReqs.length" class="fd-wo-empty">Aucune demande en attente</div>
<div v-for="req in pendingReqs" :key="req.name||req.ref"
class="fd-wo-card-v" :class="{ 'fd-wo-urgent': req.urgency==='urgent' }"
draggable="true"
@dragstart="onPendingDragStart($event,req)"
@dragend="onDragEnd"
@click="pendingModal=req; pendingAssignTech=''">
<div class="fd-wo-stripe" :style="'background:'+(req.urgency==='urgent'?'#ef4444':'#f59e0b')"></div>
<div class="fd-wo-inner">
<div class="fd-wo-top">
<span class="ticket-id">{{ req.name||req.ref }}</span>
<span v-if="req.urgency==='urgent'" class="ticket-priority priority-high">🚨</span>
</div>
<div class="fd-wo-subject">{{ SVC_ICONS[req.service_type]||'🔧' }} {{ req.problem_type||req.service_type }}</div>
<div class="fd-wo-addr">📍 {{ req.address }}</div>
<div v-if="req.budget_label" style="font-size:0.68rem;color:#f59e0b;margin-top:0.1rem;">💰 {{ req.budget_label }}</div>
</div>
</div>
</template>
</div>
<!-- Footer -->
<div class="fd-res-footer">
<button class="fd-btn fd-btn-sm" @click="undoLast" title="Ctrl+Z">↩ Annuler</button>
<span class="fd-erp-label">{{ { ok:'ERPNext ✓', error:'ERP hors ligne', loading:'Connexion…' }[store.erpStatus] || '' }}</span>
</div>
</div><!-- /.fd-left-body -->
<!-- Resize handle (right edge of left panel) -->
<div v-show="leftPanelOpen" class="fd-resize-handle" @mousedown.prevent="startResizeLeft"></div>
</div>
<!-- ── CENTER : Gantt timeline ── -->
<div class="fd-gantt-wrap" ref="schScroll" @mousedown="onSchMouseDown">
<div class="sch-inner" :style="'width:' + (140 + totalW) + 'px'" :class="{ 'sch-lassoing': lasso }">
<div v-if="lasso" class="sch-lasso" :style="lassoStyle"></div>
<!-- Pending overlay -->
<template v-if="showPendingLayer">
<div v-for="pb in pendingBlocks" :key="pb.key"
class="pending-req-block"
:class="{ 'pending-urgent': pb.req.urgency==='urgent', 'pending-p1': pb.priority===1 }"
:style="{ left:(140+pb.left)+'px', width:pb.width+'px', top:'46px', height:(scheduleRows.length*72)+'px' }"
draggable="true"
@dragstart="onPendingDragStart($event, pb.req)"
@dragend="onDragEnd"
@click.stop="pendingModal=pb.req; pendingAssignTech=''">
<div class="pending-req-inner">
<span class="pending-icon">{{ pb.icon }}</span>
<span class="pending-name">{{ pb.req.customer_name || pb.req.name }}</span>
<span class="pending-slot">{{ pb.slotLabel }}</span>
<span class="pending-date">{{ pb.dateLabel }}</span>
<span class="pending-budget" v-if="pb.req.budget_label">💰 {{ pb.req.budget_label }}</span>
</div>
</div>
<div v-if="pendingLoading" class="pending-loading-badge">Chargement demandes…</div>
<div v-else-if="showPendingLayer && pendingBlocks.length===0 && pendingReqs.length>0"
class="pending-no-match">Aucune demande pour cette période</div>
</template>
<!-- Ruler -->
<div class="sch-header-row">
<div class="sch-corner">Technicien</div>
<div class="sch-ruler" :style="'width:' + totalW + 'px'">
<div v-for="tick in rulerTicks" :key="tick.x"
:class="tick.isMajor ? 'sch-tick-major' : 'sch-tick-minor'"
:style="'left:' + tick.x + 'px'">
<span v-if="tick.label" class="sch-tick-label"
:class="{ 'sch-tick-label-current': tick.isCurrent, 'sch-tick-label-zoomable': zoomView==='week' && tick.date }"
:title="zoomView==='week' && tick.date ? 'Double-clic pour voir ce jour' : undefined"
@dblclick="zoomView==='week' && tick.date && zoomToDay(tick.date)">{{ tick.label }}</span>
</div>
<div v-if="nowLineX >= 0" class="sch-now-line" :style="'left:'+nowLineX+'px'"></div>
</div>
</div>
<!-- Tech rows -->
<div v-for="row in scheduleRows" :key="row.tech.id"
class="sch-row" :class="{ 'sch-row-active': selectedTechId===row.tech.id }"
@dragover="onSchDragOverRow($event, row)"
@dragleave="onSchDragLeaveRow($event, row)"
@drop="onSchDropRow($event, row)">
<div class="sch-tech-name" @click="selectTech(row.tech.id)" style="cursor:pointer;"
:style="selectedTechId===row.tech.id ? 'border-left:3px solid '+row.color+';padding-left:0.6rem;' : ''">
<div style="font-size:0.82rem;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:100%;">
{{ row.tech.fullName }}
</div>
<div style="display:flex;align-items:center;gap:0.35rem;margin-top:0.2rem;">
<span class="status-badge" :class="row.st.cls">{{ row.st.label }}</span>
<span style="font-size:0.62rem;color:var(--text-secondary);font-weight:600;">{{ row.loadH }}h</span>
</div>
</div>
<div class="sch-timeline" :style="'width:' + totalW + 'px'">
<div v-for="tick in rulerTicks.filter(t => t.isMajor && t.x > 0)"
:key="'hg-' + tick.x" class="sch-hour-guide" :style="{ left: tick.x+'px' }"></div>
<template v-if="zoomView === 'week'">
<div v-for="d in weekDays" :key="'dbg-'+d"
class="sch-day-bg" :class="{ 'sch-day-bg-alt': d%2===0 }"
:style="{ left:((d-1)*24*60*pxPerMin)+'px', width:(24*60*pxPerMin)+'px' }"
@dblclick.stop="zoomToDay(rulerTicks.find(t=>t.date && Math.round(t.x/(24*60*pxPerMin))===d-1)?.date)"></div>
<div v-for="d in weekDays" :key="'ds-'+d" class="sch-day-sep"
:style="{ left:((d-1)*24*60*pxPerMin)+'px' }"></div>
</template>
<div v-if="schDropTarget?.techId===row.tech.id && schDropTarget.snapDay!==undefined"
class="sch-day-drop-col" :style="{ left:schDropTarget.xPos+'px', width:schDropTarget.snapWidth+'px' }"></div>
<div v-else-if="schDropTarget?.techId===row.tech.id"
class="sch-drop-indicator" :style="{ left:schDropTarget.xPos+'px' }"></div>
<div v-for="block in row.blocks" :key="block.key"
:class="[block.type==='job'?'sch-block sch-job-block':'sch-travel-label', block.type==='job'&&isJobSelected(block.job.id)?'sch-block-selected':'']"
:style="{ left:block.left+'px', width:block.width+'px', background:block.type==='job'?block.color+'ee':undefined }"
:draggable="block.type==='job'"
@dragstart="block.type==='job' && onSchBlockDragStart($event,row,block)"
@dragend="onDragEnd"
@click="block.type==='job' && handleJobClick($event,block.job.id)"
@dblclick="block.type==='job' && openModal(block.job)">
<template v-if="block.type==='job'">
<div class="sch-block-label" style="pointer-events:none;">{{ jobLabelMap[block.job.id] }}</div>
<div class="sch-block-inner" style="pointer-events:none;">
<div class="sch-block-title">{{ block.job.subject }}</div>
<div class="sch-block-time">{{ block.timeRange }}</div>
<div v-if="block.timeHours && block.timeHours!==block.timeRange" class="sch-block-hours">{{ block.timeHours }}</div>
</div>
</template>
<template v-else>→ {{ block.driveMin }}m</template>
</div>
</div>
</div>
</div>
</div>
<!-- ── RIGHT : Map (resize handle + collapsable) ── -->
<div class="fd-resize-handle fd-resize-map" v-show="mapPanelOpen" @mousedown.prevent="startResizeMap"></div>
<div class="fd-mapside" :style="mapPanelOpen ? 'width:'+mapPanelW+'px;min-width:'+mapPanelW+'px' : 'width:36px;min-width:36px'">
<!-- Collapse toggle -->
<button class="fd-panel-toggle fd-panel-toggle-map" @click="mapPanelOpen=!mapPanelOpen" :title="mapPanelOpen?'Réduire carte':'Ouvrir carte'">
{{ mapPanelOpen ? '▶' : '◀' }}
</button>
<template v-if="mapPanelOpen">
<div class="fd-map-legend">
<div v-for="(col, label) in SVC_COLORS" :key="label" class="fd-legend-item">
<span class="fd-legend-dot" :style="'background:'+col"></span>
<span>{{ label }}</span>
</div>
<div class="fd-legend-divider" v-if="selectedTechId"></div>
<div v-if="selectedTechId" class="fd-legend-item">
<span class="fd-legend-dot" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===selectedTechId)?.colorIdx]"></span>
<span style="font-weight:600;">{{ store.technicians.find(t=>t.id===selectedTechId)?.fullName }}</span>
</div>
<div v-else class="fd-legend-hint">↑ Cliquer un technicien pour son parcours</div>
</div>
<div id="map"></div>
</template>
</div>
</div><!-- /.fd-main -->
<!-- ── Modal demande en attente ── -->
<div v-if="pendingModal" class="modal-overlay" @click.self="pendingModal=null">
<div class="modal-card" @click.stop style="max-width:480px;">
<div class="modal-header">
<span class="ticket-id" style="font-size:0.9rem;">{{ pendingModal.name }}</span>
<span v-if="pendingModal.urgency==='urgent'" class="ticket-priority priority-high">🚨 Urgent</span>
<button class="modal-close" @click="pendingModal=null">✕</button>
</div>
<p class="modal-title">
{{ SVC_ICONS[pendingModal.service_type]||'🔧' }}
{{ pendingModal.problem_type||pendingModal.service_type }}
</p>
<div class="modal-row">👤 <strong>{{ pendingModal.customer_name }}</strong>
<span v-if="pendingModal.phone" style="color:var(--text-secondary);margin-left:0.5rem;">{{ pendingModal.phone }}</span>
</div>
<div class="modal-row">📍 <strong>{{ pendingModal.address }}</strong></div>
<div v-if="pendingModal.description" class="modal-row" style="font-size:0.82rem;color:var(--text-secondary);">
{{ pendingModal.description }}
</div>
<div v-if="pendingModal.budget_label" class="modal-row">💰 Budget : <strong>{{ pendingModal.budget_label }}</strong></div>
<div style="margin-bottom:0.75rem;font-size:0.72rem;font-weight:800;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-secondary);">
Dates préférées
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.4rem;margin-bottom:1rem;">
<div v-for="pd in reqDates(pendingModal)" :key="pd.priority"
style="background:rgba(99,102,241,0.12);border:1px solid rgba(99,102,241,0.3);border-radius:8px;padding:0.4rem 0.75rem;font-size:0.78rem;">
<span style="color:var(--text-secondary);font-size:0.68rem;">{{ pd.priority }}e choix · </span>
<strong>{{ new Date(pd.date+'T12:00:00').toLocaleDateString('fr-CA',{weekday:'short',day:'numeric',month:'short'}) }}</strong>
<span style="color:#818cf8;margin-left:0.35rem;">{{ pd.slots.map(s=>({morning:'Matin',afternoon:'Après-midi',evening:'Soir',flexible:'Flexible'}[s]||s)).join(' · ') }}</span>
</div>
</div>
<div style="margin-bottom:0.75rem;font-size:0.72rem;font-weight:800;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-secondary);">
Assigner à un technicien
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem;">
<button v-for="tech in store.technicians" :key="tech.id"
class="sch-zoom-btn"
:class="{ active: pendingAssignTech===tech.id }"
:style="pendingAssignTech===tech.id?`border-color:${TECH_COLORS[tech.colorIdx]};background:${TECH_COLORS[tech.colorIdx]}22`:''"
@click="pendingAssignTech=tech.id">
{{ tech.fullName }}
</button>
</div>
<div class="modal-footer">
<button class="btn-complete-modal" :disabled="!pendingAssignTech"
@click="acceptPendingReq(pendingModal, pendingAssignTech)">
✓ Accepter &amp; assigner
</button>
<button class="sch-revert-btn" @click="pendingModal=null">Fermer</button>
</div>
</div>
</div>
<!-- ── Modal ticket ── -->
<div v-if="modalTicket" class="modal-overlay" @click.self="closeModal">
<div class="modal-card" @click.stop>
<div class="modal-header">
<span class="ticket-id" style="font-size:0.9rem;">{{ modalTicket.id }}</span>
<span class="ticket-priority" :class="prioClass(modalTicket.priority)">{{ prioLabel(modalTicket.priority) }}</span>
<span v-if="modalTicket.status==='completed'" class="status-badge status-in-progress">✓ Complété</span>
<button class="modal-close" @click="closeModal">✕</button>
</div>
<p class="modal-title">{{ modalTicket.subject }}</p>
<div class="modal-row">📍 <strong>{{ modalTicket.address }}</strong></div>
<div class="modal-row">⏱️ Durée estimée : <strong>{{ modalTicket.duration }}h</strong></div>
<div v-if="modalTicket.assignedTech" class="modal-row">
👤 Technicien :
<strong :style="'color:'+TECH_COLORS[store.technicians.find(t=>t.id===modalTicket.assignedTech)?.colorIdx||0]">
■ {{ store.technicians.find(t=>t.id===modalTicket.assignedTech)?.fullName }}
</strong>
</div>
<div v-else class="modal-row" style="color:var(--orange);">⚠️ Non assigné</div>
<div v-if="modalTicket.legDist" class="modal-row">
🚗 Trajet : <strong>{{ modalTicket.legDist }} km ({{ modalTicket.legDur }} min)</strong>
</div>
<div class="modal-footer">
<button v-if="modalTicket.status!=='completed'" class="btn-complete-modal"
@click="completeTicket(modalTicket.id); closeModal()">
✓ Marquer comme complété
</button>
<span v-else style="color:var(--green);font-size:0.85rem;font-weight:600;">✓ Ticket complété</span>
<button class="sch-revert-btn" @click="closeModal">Fermer</button>
</div>
</div>
</div>
<!-- ── Tooltip ── -->
<div v-if="tooltipTicket" id="ticketTooltip" :style="tooltipStyle">
<div style="font-weight:700;margin-bottom:0.35rem;color:var(--text-primary);">
{{ tooltipTicket.id }} {{ tooltipTicket.subject }}
</div>
<div style="color:var(--text-secondary);margin-bottom:0.25rem;">📍 {{ tooltipTicket.address }}</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;font-size:0.72rem;">
<span> {{ tooltipTicket.duration }}h</span>
<span v-if="tooltipTicket.assignedTech">
👤 {{ store.technicians.find(t=>t.id===tooltipTicket.assignedTech)?.fullName }}
</span>
<span v-if="tooltipTicket.status==='completed'" style="color:var(--green);font-weight:700;"> Complété</span>
</div>
</div>
</div>
</template>
<style scoped>
/* ═══════════════════════════════════════════════════
CSS Variables (dark / light)
═══════════════════════════════════════════════════ */
:root {
--bg: #0f1117; --sidebar-bg: #151820; --card-bg: #1e2130; --card-hover: #252840;
--border: rgba(255,255,255,0.07); --border-accent: rgba(99,102,241,0.45);
--text-primary: #e8eaf0; --text-secondary: #8b90a8;
--accent: #6366f1; --red: #f43f5e; --green: #10b981; --orange: #f59e0b;
}
.light-mode {
--bg: #f0f2f8; --sidebar-bg: #ffffff; --card-bg: #ffffff; --card-hover: #f5f7ff;
--border: rgba(0,0,0,0.09); --border-accent: rgba(99,102,241,0.4);
--text-primary: #1e2130; --text-secondary: #6b7280;
}
/* ═══════════════════════════════════════════════════
Root layout — 2 rows: topbar | main
═══════════════════════════════════════════════════ */
:global(html), :global(body) { margin: 0; padding: 0; height: 100%; overflow: hidden; }
.fd-root {
display: grid;
grid-template-rows: 44px 1fr;
grid-template-columns: 1fr;
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--bg);
color: var(--text-primary);
font-family: 'Inter', sans-serif;
}
/* ═══════════════════════════════════════════════════
Top action bar
═══════════════════════════════════════════════════ */
.fd-topbar {
grid-row: 1;
display: flex; align-items: center; gap: 0.75rem;
padding: 0 1rem;
background: var(--sidebar-bg);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 8px rgba(0,0,0,0.35);
z-index: 20;
}
.fd-tb-left { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.fd-tb-center{ display: flex; align-items: center; gap: 0.5rem; flex: 1; justify-content: center; }
.fd-tb-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; margin-left: auto; }
.fd-logo { display: flex; align-items: center; gap: 0.4rem; margin-right: 0.5rem; }
.fd-logo-icon { width: 26px; height: 26px; background: linear-gradient(135deg, var(--accent), #a855f7); border-radius: 6px; display: flex; align-items: center; justify-content: center; }
.fd-logo-text { font-size: 1rem; font-weight: 700; }
.fd-btn { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); font-size: 0.75rem; font-weight: 600; padding: 0.3rem 0.65rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; white-space: nowrap; }
.fd-btn:hover { border-color: var(--border-accent); background: var(--card-hover); }
.fd-btn-green { border-color: rgba(16,185,129,0.3); color: var(--green); }
.fd-btn-green:hover { background: rgba(16,185,129,0.1); }
.fd-btn-pending { border-color: rgba(245,158,11,0.3); color: var(--orange); }
.fd-btn-pending.active { background: rgba(245,158,11,0.15); border-color: var(--orange); }
.fd-btn-sm { font-size: 0.68rem; padding: 0.2rem 0.45rem; }
.fd-btn-icon { background: none; border: none; cursor: pointer; font-size: 1rem; padding: 0 0.2rem; opacity: 0.7; }
.fd-btn-icon:hover { opacity: 1; }
.fd-nav-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); font-size: 1rem; padding: 0.15rem 0.6rem; cursor: pointer; }
.fd-nav-btn:hover { background: var(--card-bg); }
.fd-date-label { font-size: 0.82rem; font-weight: 600; color: var(--text-primary); min-width: 160px; text-align: center; }
.fd-zoom-toggle { display: flex; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.fd-zoom-toggle button { background: none; border: none; color: var(--text-secondary); font-size: 0.72rem; font-weight: 600; padding: 0.25rem 0.6rem; cursor: pointer; transition: color 0.15s, background 0.15s; }
.fd-zoom-toggle button.active { background: var(--accent); color: white; }
.fd-badge-sm { background: var(--accent); color: white; font-size: 0.6rem; padding: 0.05rem 0.35rem; border-radius: 8px; font-weight: 700; margin-left: 0.25rem; }
.fd-erp-dot { width: 7px; height: 7px; border-radius: 50%; background: #ef4444; transition: background 0.3s; }
.fd-erp-dot.connected { background: var(--green); }
/* ═══════════════════════════════════════════════════
Main area — 3 columns: left | gantt | map
═══════════════════════════════════════════════════ */
.fd-main {
grid-row: 2;
display: flex;
overflow: hidden;
min-height: 0;
}
/* ── Left panel (resizable, collapsable) ── */
.fd-left {
display: flex;
flex-direction: row;
flex-shrink: 0;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
overflow: hidden;
transition: width 0.18s, min-width 0.18s;
position: relative;
}
.fd-left-body {
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
}
/* Panel collapse toggle (left & map) */
.fd-panel-toggle {
position: absolute; top: 50%; transform: translateY(-50%);
right: -1px; z-index: 15;
width: 16px; min-width: 16px; height: 40px;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 0 6px 6px 0;
color: var(--text-secondary); font-size: 0.55rem;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: color 0.15s, background 0.15s;
padding: 0;
}
.fd-panel-toggle:hover { color: var(--accent); background: var(--card-hover); }
.fd-panel-toggle-map {
right: auto; left: -1px;
border-radius: 6px 0 0 6px;
}
/* Accordion headers */
.fd-acc-hdr {
padding: 0.4rem 0.65rem;
font-size: 0.6rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
cursor: pointer; flex-shrink: 0;
transition: background 0.12s;
}
.fd-acc-hdr:hover { background: var(--card-hover); }
.fd-acc-hdr-wo { padding: 0.25rem 0.4rem 0.25rem 0.5rem; cursor: default; }
.fd-chevron { font-size: 0.55rem; opacity: 0.6; margin-left: 0.3rem; flex-shrink: 0; }
/* Accordion body — tech list */
.fd-acc-body { overflow-y: auto; }
.fd-acc-body::-webkit-scrollbar { width: 3px; }
.fd-acc-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); }
.fd-res-list { flex-shrink: 0; max-height: 45vh; }
/* Work orders list */
.fd-wo-list { flex: 1; min-height: 0; padding: 0.3rem 0; }
.fd-wo-tabs-inline { display: flex; gap: 0.15rem; align-items: center; flex: 1; min-width: 0; }
.fd-wo-tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); font-size: 0.65rem; font-weight: 700; padding: 0.2rem 0.35rem 0.15rem; cursor: pointer; text-transform: uppercase; letter-spacing: 0.04em; transition: color 0.15s, border-color 0.15s; white-space: nowrap; }
.fd-wo-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.fd-wo-empty { font-size: 0.75rem; color: var(--text-secondary); font-style: italic; padding: 0.75rem 0.75rem; }
.fd-wo-card-v {
display: flex; overflow: hidden; cursor: grab;
border-bottom: 1px solid var(--border);
transition: background 0.12s;
}
.fd-wo-card-v:hover { background: var(--card-hover); }
.fd-wo-card-sel { background: rgba(99,102,241,0.07) !important; }
.fd-wo-urgent { border-left: 2px solid rgba(239,68,68,0.5); }
/* shared wo inner (reused for card-v) */
.fd-section-hdr { padding: 0.5rem 0.75rem; font-size: 0.62rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-secondary); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.fd-res-row {
position: relative;
display: flex; align-items: flex-start; gap: 0.5rem;
padding: 0.6rem 0.75rem 0.6rem 0.5rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
user-select: none;
}
.fd-res-row:hover { background: var(--card-hover); }
.fd-res-selected { background: rgba(99,102,241,0.06) !important; }
.fd-res-sel-bar { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; border-radius: 0 2px 2px 0; transition: background 0.2s; }
.fd-res-avatar {
width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 0.7rem; font-weight: 700; color: white; flex-shrink: 0; margin-top: 2px;
}
.fd-res-info { flex: 1; min-width: 0; }
.fd-res-name { font-size: 0.8rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fd-res-meta { display: flex; align-items: center; gap: 0.3rem; margin: 0.15rem 0; flex-wrap: wrap; }
.fd-res-jobs { font-size: 0.62rem; color: var(--text-secondary); }
.fd-res-bar { height: 3px; background: var(--border); border-radius: 2px; margin-top: 0.25rem; overflow: hidden; }
.fd-res-bar-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
.fd-res-rating { display: flex; align-items: center; position: relative; margin-top: 0.2rem; cursor: pointer; }
.fd-drop-glow { position: absolute; inset: 0; border: 2px dashed var(--accent); border-radius: 4px; background: rgba(99,102,241,0.05); pointer-events: none; }
.fd-sel-toolbar { padding: 0.4rem 0.5rem; background: rgba(99,102,241,0.1); border-top: 1px solid var(--border-accent); display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; font-size: 0.7rem; }
.fd-sel-count { font-weight: 700; color: var(--accent); }
.fd-sel-transfer { background: var(--card-bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font-size: 0.68rem; padding: 0.15rem 0.3rem; cursor: pointer; }
.fd-sel-btn { background: none; border: 1px solid var(--border); border-radius: 4px; color: var(--text-secondary); font-size: 0.65rem; padding: 0.15rem 0.4rem; cursor: pointer; }
.fd-sel-btn:hover { color: var(--red); border-color: var(--red); }
.fd-res-footer { padding: 0.4rem 0.5rem; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.fd-erp-label { font-size: 0.58rem; color: var(--text-secondary); }
/* ── Center: Gantt ── */
.fd-gantt-wrap {
flex: 1;
overflow-x: auto;
overflow-y: auto;
position: relative;
min-width: 0;
}
.fd-gantt-wrap::-webkit-scrollbar { height: 5px; width: 5px; }
.fd-gantt-wrap::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 3px; }
/* ── Resize handles ── */
.fd-resize-handle {
width: 4px; min-width: 4px; flex-shrink: 0;
background: transparent;
cursor: col-resize;
transition: background 0.15s;
z-index: 10;
}
.fd-resize-handle:hover { background: var(--accent); }
.fd-resize-map { order: -1; }
/* ── Right: Map (resizable, collapsable) ── */
.fd-mapside {
display: flex; flex-direction: column;
border-left: 1px solid var(--border);
overflow: hidden; flex-shrink: 0;
transition: width 0.18s, min-width 0.18s;
position: relative;
}
.fd-map-legend {
padding: 0.4rem 0.6rem;
background: var(--sidebar-bg);
border-bottom: 1px solid var(--border);
display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; align-items: center;
flex-shrink: 0;
}
.fd-legend-item { display: flex; align-items: center; gap: 0.25rem; font-size: 0.65rem; color: var(--text-secondary); }
.fd-legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.fd-legend-divider { width: 1px; height: 12px; background: var(--border); margin: 0 0.25rem; }
.fd-legend-hint { font-size: 0.62rem; color: var(--text-secondary); font-style: italic; }
#map { flex: 1; min-height: 0; }
/* shared wo card parts */
.fd-wo-stripe { width: 4px; flex-shrink: 0; }
.fd-wo-inner { padding: 0.4rem 0.5rem; flex: 1; min-width: 0; }
.fd-wo-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.15rem; }
.fd-wo-subject { font-size: 0.75rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fd-wo-addr { font-size: 0.63rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ═══════════════════════════════════════════════════
Gantt inner components (unchanged)
═══════════════════════════════════════════════════ */
.sch-inner { position: relative; display: flex; flex-direction: column; min-height: 100%; }
.sch-lassoing { cursor: crosshair; }
.sch-lasso { position: absolute; z-index: 30; border: 1.5px dashed var(--accent); background: rgba(99,102,241,0.08); pointer-events: none; }
.sch-header-row { position: sticky; top: 0; z-index: 11; display: flex; height: 46px; background: var(--sidebar-bg); border-bottom: 1px solid var(--border); flex-shrink: 0; }
.sch-corner { position: sticky; left: 0; top: 0; z-index: 12; width: 140px; min-width: 140px; flex-shrink: 0; background: var(--sidebar-bg); border-right: 1px solid var(--border); display: flex; align-items: center; padding: 0 0.75rem; font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); }
.sch-ruler { position: relative; flex: 1; height: 100%; }
.sch-tick-major { position: absolute; top: 0; height: 100%; border-left: 1px solid var(--border); }
.sch-tick-minor { position: absolute; bottom: 0; height: 35%; border-left: 1px dashed rgba(255,255,255,0.08); }
.sch-tick-label { position: absolute; top: 5px; left: 4px; font-size: 0.62rem; color: var(--text-secondary); white-space: nowrap; font-weight: 600; user-select: none; }
.sch-tick-label-current { color: #f43f5e; font-weight: 700; }
.sch-tick-label-zoomable { cursor: zoom-in; border-bottom: 1px dashed currentColor; opacity: 0.85; transition: opacity 0.15s; }
.sch-tick-label-zoomable:hover { opacity: 1; color: var(--accent); }
.sch-now-line { position: absolute; top: 0; width: 2px; height: 100%; background: #f43f5e; opacity: 0.8; pointer-events: none; }
.sch-row { display: flex; height: 72px; border-bottom: 1px solid var(--border); transition: background 0.15s; }
.sch-row-active { background: rgba(99,102,241,0.04); }
.sch-tech-name { position: sticky; left: 0; z-index: 5; width: 140px; min-width: 140px; flex-shrink: 0; padding: 0 0.75rem; display: flex; flex-direction: column; justify-content: center; background: var(--sidebar-bg); border-right: 1px solid var(--border); transition: border-left 0.15s; }
.sch-timeline { position: relative; flex: 1; height: 100%; }
.sch-hour-guide { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); opacity: 0.5; pointer-events: none; }
.sch-day-bg { position: absolute; top: 0; bottom: 0; pointer-events: auto; }
.sch-day-bg-alt { background: rgba(255,255,255,0.015); }
.sch-day-sep { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border); opacity: 0.7; pointer-events: none; }
.sch-drop-indicator { position: absolute; top: 4px; bottom: 4px; width: 2px; background: var(--accent); border-radius: 2px; pointer-events: none; z-index: 10; }
.sch-day-drop-col { position: absolute; top: 0; bottom: 0; background: rgba(99,102,241,0.1); border: 1px dashed var(--accent); border-radius: 4px; pointer-events: none; z-index: 8; }
.sch-block {
position: absolute; top: 8px; bottom: 8px; border-radius: 8px;
display: flex; align-items: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: box-shadow 0.15s, transform 0.15s;
cursor: grab; overflow: hidden; z-index: 4;
}
.sch-block:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.45); transform: translateY(-1px); z-index: 5; }
.sch-block-selected { outline: 2px solid white; z-index: 6; }
.sch-block-label { width: 22px; min-width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; font-size: 0.62rem; font-weight: 700; color: white; margin-left: 6px; flex-shrink: 0; }
.sch-block-inner { flex: 1; min-width: 0; padding: 0 6px; }
.sch-block-title { font-size: 0.72rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: white; }
.sch-block-time { font-size: 0.6rem; color: rgba(255,255,255,0.75); white-space: nowrap; }
.sch-block-hours { font-size: 0.58rem; color: rgba(255,255,255,0.6); white-space: nowrap; }
.sch-travel-label { position: absolute; top: 50%; transform: translateY(-50%); font-size: 0.6rem; color: var(--text-secondary); white-space: nowrap; padding: 0 4px; pointer-events: none; }
/* Pending overlay */
.pending-req-block { position: absolute; z-index: 3; pointer-events: auto; cursor: pointer; overflow: hidden; border-radius: 4px; background: repeating-linear-gradient(-45deg, rgba(245,158,11,0.18) 0, rgba(245,158,11,0.18) 6px, transparent 6px, transparent 12px); border: 1px solid rgba(245,158,11,0.35); transition: border-color 0.15s; }
.pending-req-block:hover { border-color: rgba(245,158,11,0.7); }
.pending-urgent { background: repeating-linear-gradient(-45deg, rgba(239,68,68,0.22) 0, rgba(239,68,68,0.22) 6px, transparent 6px, transparent 12px); border-color: rgba(239,68,68,0.4); }
.pending-req-inner { display: flex; flex-direction: column; gap: 1px; padding: 4px 6px; }
.pending-icon { font-size: 0.75rem; }
.pending-name { font-size: 0.65rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--orange); }
.pending-slot, .pending-date { font-size: 0.58rem; color: var(--text-secondary); white-space: nowrap; }
.pending-budget { font-size: 0.58rem; color: var(--green); }
.pending-loading-badge { position: sticky; top: 50px; left: 150px; background: var(--accent); color: white; font-size: 0.72rem; padding: 0.3rem 0.75rem; border-radius: 20px; display: inline-block; z-index: 15; }
.pending-no-match { position: sticky; top: 50px; left: 150px; color: var(--text-secondary); font-size: 0.72rem; font-style: italic; padding: 0.3rem 0.75rem; display: inline-block; }
/* Status badges */
.status-badge { font-size: 0.62rem; padding: 0.1rem 0.4rem; border-radius: 6px; font-weight: 600; white-space: nowrap; }
.status-on-shift { background: rgba(16,185,129,0.15); color: var(--green); }
.status-enroute { background: rgba(245,158,11,0.15); color: var(--orange); }
.status-in-progress { background: rgba(99,102,241,0.15); color: #818cf8; }
.status-off { background: rgba(239,68,68,0.1); color: var(--red); }
/* Priority badges */
.ticket-priority { font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 8px; font-weight: 600; }
.priority-high { background: rgba(239,68,68,0.2); color: var(--red); }
.priority-medium { background: rgba(245,158,11,0.2); color: var(--orange); }
.priority-low { background: rgba(16,185,129,0.2); color: var(--green); }
.job-label-badge { background: rgba(99,102,241,0.2); color: #818cf8; font-size: 0.65rem; font-weight: 700; padding: 0.05rem 0.35rem; border-radius: 6px; }
.ticket-id { font-size: 0.72rem; font-weight: 600; color: var(--accent); }
/* Star rating */
.star-display { position: relative; display: inline-block; }
.stars-bg { color: rgba(255,255,255,0.15); letter-spacing: 1px; }
.stars-fg { position: absolute; left: 0; top: 0; overflow: hidden; white-space: nowrap; color: #f59e0b; letter-spacing: 1px; }
.rating-input { display: flex; align-items: center; gap: 0.1rem; margin-top: 0.2rem; }
.star-btn { cursor: pointer; font-size: 1rem; color: rgba(255,255,255,0.2); transition: color 0.1s; }
.star-btn.active { color: #f59e0b; }
.rating-val { font-size: 0.72rem; font-weight: 700; color: #f59e0b; }
.rating-cnt { font-size: 0.65rem; color: var(--text-secondary); }
.rating-hint { font-size: 0.65rem; color: var(--text-secondary); margin-left: 0.25rem; }
/* Zoom btn (reused in pending modal) */
.sch-zoom-btn { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-secondary); font-size: 0.72rem; font-weight: 600; padding: 0.3rem 0.75rem; cursor: pointer; transition: color 0.15s, border-color 0.15s, background 0.15s; }
.sch-zoom-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(99,102,241,0.1); }
/* Modals */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.65); backdrop-filter: blur(4px); z-index: 100; display: flex; align-items: center; justify-content: center; }
.modal-card { background: var(--sidebar-bg); border: 1px solid var(--border); border-radius: 16px; padding: 1.5rem; min-width: 340px; max-width: 520px; width: 100%; box-shadow: 0 24px 60px rgba(0,0,0,0.55); }
.modal-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
.modal-title { font-size: 1.05rem; font-weight: 700; margin: 0 0 0.75rem; }
.modal-row { font-size: 0.85rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
.modal-footer { display: flex; gap: 0.5rem; margin-top: 1rem; align-items: center; }
.modal-close { margin-left: auto; background: none; border: none; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; }
.modal-close:hover { color: var(--red); }
.btn-complete-modal { background: var(--green); color: white; border: none; border-radius: 8px; padding: 0.5rem 1rem; font-size: 0.82rem; font-weight: 700; cursor: pointer; }
.btn-complete-modal:disabled { opacity: 0.4; cursor: not-allowed; }
.sch-revert-btn { background: none; border: 1px solid var(--border); border-radius: 8px; color: var(--text-secondary); padding: 0.5rem 0.75rem; font-size: 0.8rem; cursor: pointer; }
.sch-revert-btn:hover { border-color: var(--border-accent); }
/* Tooltip */
#ticketTooltip { position: fixed; z-index: 200; background: var(--sidebar-bg); border: 1px solid var(--border-accent); border-radius: 10px; padding: 0.75rem 1rem; max-width: 280px; box-shadow: 0 8px 24px rgba(0,0,0,0.45); font-size: 0.8rem; pointer-events: none; }
</style>