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>
1960 lines
95 KiB
Vue
1960 lines
95 KiB
Vue
<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 A–Z (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 (lun–ven) ou 7 (lun–dim) 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é (0–4), 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 & 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>
|