@@ -245,38 +201,77 @@
{{ m.date }} --- {{ m.from_location || '?' }} → {{ m.to_location || '?' }} {{ m.reason ? '(' + m.reason + ')' : '' }}
+
+
+
+
diff --git a/apps/ops/src/composables/useAddressSearch.js b/apps/ops/src/composables/useAddressSearch.js
new file mode 100644
index 0000000..a57ceb0
--- /dev/null
+++ b/apps/ops/src/composables/useAddressSearch.js
@@ -0,0 +1,37 @@
+import { ref } from 'vue'
+
+/**
+ * Reusable address search composable.
+ * Searches ERPNext `search_address` whitelisted method with debounce.
+ */
+export function useAddressSearch () {
+ const addrResults = ref([])
+ const addrLoading = ref(false)
+ let addrDebounce = null
+
+ function searchAddr (q) {
+ clearTimeout(addrDebounce)
+ if (!q || q.length < 3) { addrResults.value = []; return }
+ addrLoading.value = true
+ addrDebounce = setTimeout(async () => {
+ try {
+ const res = await fetch(`/api/method/search_address?q=${encodeURIComponent(q)}`, { credentials: 'include' })
+ const data = await res.json()
+ addrResults.value = data.results || data.message?.results || []
+ } catch { addrResults.value = [] }
+ addrLoading.value = false
+ }, 300)
+ }
+
+ function selectAddr (addr, target) {
+ if (!target) { addrResults.value = []; return }
+ target.address = addr.address_full
+ if (addr.latitude) target.latitude = parseFloat(addr.latitude)
+ if (addr.longitude) target.longitude = parseFloat(addr.longitude)
+ if (addr.ville) target.ville = addr.ville
+ if (addr.code_postal) target.code_postal = addr.code_postal
+ addrResults.value = []
+ }
+
+ return { addrResults, addrLoading, searchAddr, selectAddr }
+}
diff --git a/apps/ops/src/composables/useBestTech.js b/apps/ops/src/composables/useBestTech.js
new file mode 100644
index 0000000..49f36a1
--- /dev/null
+++ b/apps/ops/src/composables/useBestTech.js
@@ -0,0 +1,157 @@
+// ── Find optimal technician for emergency mid-day job insertion ───────────────
+// Scores each tech based on: GPS proximity, current load, skills match, queue fit
+// Uses real-time GPS when available, falls back to last job coords or home base
+// ─────────────────────────────────────────────────────────────────────────────
+
+import { MAPBOX_TOKEN } from 'src/config/erpnext'
+
+// Euclidean distance approximation in km (Montreal latitude)
+function distKm (a, b) {
+ if (!a || !b || (!a[0] && !a[1]) || (!b[0] && !b[1])) return 999
+ const dx = (a[0] - b[0]) * 80 // 1° lng ≈ 80 km at 45°N
+ const dy = (a[1] - b[1]) * 111 // 1° lat ≈ 111 km
+ return Math.sqrt(dx * dx + dy * dy)
+}
+
+// Get tech's effective current position: GPS > last queued job > home
+function techCurrentPos (tech, dateStr) {
+ if (tech.gpsCoords && tech.gpsOnline) return { coords: tech.gpsCoords, source: 'gps' }
+ const todayJobs = tech.queue.filter(j => j.scheduledDate === dateStr)
+ // Find last completed or in-progress job
+ const done = todayJobs.filter(j => j.status === 'completed' || j.status === 'en-route')
+ if (done.length) {
+ const last = done[done.length - 1]
+ if (last.coords && (last.coords[0] || last.coords[1])) return { coords: last.coords, source: 'lastJob' }
+ }
+ // Or next job in queue
+ if (todayJobs.length) {
+ const next = todayJobs.find(j => j.coords && (j.coords[0] || j.coords[1]))
+ if (next) return { coords: next.coords, source: 'nextJob' }
+ }
+ return { coords: tech.coords, source: 'home' }
+}
+
+// Compute load: total hours assigned today
+function techDayLoad (tech, dateStr) {
+ return tech.queue
+ .filter(j => j.scheduledDate === dateStr)
+ .reduce((sum, j) => sum + (parseFloat(j.duration) || 1), 0)
+}
+
+// Best insertion point in tech's queue (minimizes detour)
+function bestInsertionIdx (tech, jobCoords, dateStr) {
+ const dayJobs = tech.queue.filter(j => j.scheduledDate === dateStr)
+ if (!dayJobs.length) return 0
+ if (!jobCoords || (!jobCoords[0] && !jobCoords[1])) return dayJobs.length
+
+ // Try each insertion point, pick the one with least total detour
+ let bestIdx = dayJobs.length, bestDetour = Infinity
+ for (let i = 0; i <= dayJobs.length; i++) {
+ const prev = i === 0 ? (tech.gpsCoords || tech.coords) : dayJobs[i - 1].coords
+ const next = i < dayJobs.length ? dayJobs[i].coords : null
+ const directDist = next ? distKm(prev, next) : 0
+ const detour = distKm(prev, jobCoords) + (next ? distKm(jobCoords, next) : 0) - directDist
+ if (detour < bestDetour) { bestDetour = detour; bestIdx = i }
+ }
+ return bestIdx
+}
+
+/**
+ * Score and rank all technicians for an emergency job.
+ *
+ * @param {Object} params
+ * @param {Array} params.technicians - All available techs
+ * @param {Array} params.jobCoords - [lng, lat] of the emergency job
+ * @param {number} params.jobDuration - Hours needed
+ * @param {Array} params.jobTags - Required skill tags
+ * @param {string} params.dateStr - YYYY-MM-DD (today)
+ * @returns {Array} Ranked techs: [{ tech, score, distance, load, insertIdx, reasons }]
+ */
+export function rankTechs ({ technicians, jobCoords, jobDuration = 1, jobTags = [], dateStr }) {
+ const hasCoords = jobCoords && (jobCoords[0] || jobCoords[1])
+ const candidates = technicians.filter(t => t.status !== 'off' && t.status !== 'unavailable')
+
+ const scored = candidates.map(tech => {
+ const pos = techCurrentPos(tech, dateStr)
+ const distance = hasCoords ? distKm(pos.coords, jobCoords) : 999
+ const load = techDayLoad(tech, dateStr)
+ const remainingCap = Math.max(0, 8 - load)
+ const insertIdx = hasCoords ? bestInsertionIdx(tech, jobCoords, dateStr) : tech.queue.filter(j => j.scheduledDate === dateStr).length
+
+ // ── Scoring (lower = better) ──
+ let score = 0
+ const reasons = []
+
+ // 1. Proximity (weight: 40%) — distance in km, normalized to ~0-100
+ const proxScore = Math.min(distance, 100)
+ score += proxScore * 4
+ if (distance < 5) reasons.push(`📍 ${distance.toFixed(1)} km (très proche)`)
+ else if (distance < 15) reasons.push(`📍 ${distance.toFixed(1)} km`)
+ else reasons.push(`📍 ${distance.toFixed(1)} km (loin)`)
+
+ // 2. Load balance (weight: 30%) — prefer techs with capacity
+ const loadScore = load * 10 // 0-80 range
+ score += loadScore * 3
+ if (remainingCap < jobDuration) {
+ score += 500 // Heavy penalty: can't fit the job
+ reasons.push(`⚠ Surchargé (${load.toFixed(1)}h/${8}h)`)
+ } else if (load < 4) {
+ reasons.push(`✓ Dispo (${load.toFixed(1)}h/${8}h)`)
+ } else {
+ reasons.push(`◐ Chargé (${load.toFixed(1)}h/${8}h)`)
+ }
+
+ // 3. Skills match (weight: 20%)
+ if (jobTags.length) {
+ const techTags = tech.tags || []
+ const missing = jobTags.filter(t => !techTags.includes(t))
+ score += missing.length * 200
+ if (missing.length) reasons.push(`⚠ Manque: ${missing.join(', ')}`)
+ else reasons.push('✓ Skills OK')
+ }
+
+ // 4. GPS freshness bonus (weight: 10%) — trust live GPS more
+ if (pos.source === 'gps') {
+ score -= 20 // Bonus for having live GPS
+ reasons.push('🛰 GPS en direct')
+ } else {
+ reasons.push(`📌 Position: ${pos.source === 'home' ? 'domicile' : 'estimée'}`)
+ }
+
+ return { tech, score, distance, load, remainingCap, insertIdx, posSource: pos.source, reasons }
+ })
+
+ return scored.sort((a, b) => a.score - b.score)
+}
+
+/**
+ * Get real driving times from Mapbox for top N candidates (optional refinement).
+ * Updates the distance/score with actual driving duration.
+ */
+export async function refineWithDrivingTimes (ranked, jobCoords, topN = 3) {
+ if (!jobCoords || (!jobCoords[0] && !jobCoords[1])) return ranked
+ const top = ranked.slice(0, topN)
+ const rest = ranked.slice(topN)
+
+ const refined = await Promise.all(top.map(async (r) => {
+ const techPos = r.tech.gpsCoords || r.tech.coords
+ if (!techPos || (!techPos[0] && !techPos[1])) return r
+ try {
+ const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${techPos[0]},${techPos[1]};${jobCoords[0]},${jobCoords[1]}?overview=false&access_token=${MAPBOX_TOKEN}`
+ const res = await fetch(url)
+ const data = await res.json()
+ if (data.routes?.[0]) {
+ const mins = Math.round(data.routes[0].duration / 60)
+ const km = (data.routes[0].distance / 1000).toFixed(1)
+ r.drivingMins = mins
+ r.drivingKm = km
+ // Replace proximity score with actual driving time
+ r.score = mins * 4 + r.load * 30 + (r.reasons.some(r => r.includes('Manque')) ? 200 : 0)
+ r.reasons[0] = `🚗 ${mins} min (${km} km)`
+ }
+ } catch { /* keep euclidean estimate */ }
+ return r
+ }))
+
+ return [...refined.sort((a, b) => a.score - b.score), ...rest]
+}
diff --git a/apps/ops/src/composables/useConversations.js b/apps/ops/src/composables/useConversations.js
new file mode 100644
index 0000000..b602406
--- /dev/null
+++ b/apps/ops/src/composables/useConversations.js
@@ -0,0 +1,204 @@
+import { ref, computed } from 'vue'
+import { useAuthStore } from 'src/stores/auth'
+
+const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
+
+function agentHeaders (extra = {}) {
+ try {
+ const email = useAuthStore().user
+ if (email && email !== 'authenticated') return { 'Content-Type': 'application/json', 'X-Authentik-Email': email, ...extra }
+ } catch {}
+ return { 'Content-Type': 'application/json', ...extra }
+}
+
+const conversations = ref([])
+const discussions = ref([])
+const activeToken = ref(null)
+const activeDiscussion = ref(null)
+const activeConv = ref(null)
+const showAll = ref(true)
+const loading = ref(false)
+const panelOpen = ref(false)
+const selectedIds = ref(new Set())
+let sseSource = null
+
+export function useConversations () {
+ async function fetchList () {
+ loading.value = true
+ try {
+ const url = showAll.value ? `${HUB_URL}/conversations?all=1` : `${HUB_URL}/conversations`
+ const res = await fetch(url)
+ if (res.ok) {
+ const data = await res.json()
+ conversations.value = data.conversations || []
+ discussions.value = data.discussions || []
+ }
+ } catch {}
+ loading.value = false
+ }
+
+ function openDiscussion (disc) {
+ activeDiscussion.value = disc; activeToken.value = null; activeConv.value = null
+ const active = disc.conversations.find(c => c.status === 'active')
+ if (active) activeToken.value = active.token
+ connectDiscussionSSE(disc.conversations.map(c => c.token))
+ }
+
+ async function openConversation (token) {
+ activeToken.value = token
+ try { const res = await fetch(`${HUB_URL}/conversations/${token}`); if (res.ok) activeConv.value = await res.json() } catch {}
+ connectSSE(token)
+ }
+
+ function connectDiscussionSSE (tokens) {
+ if (sseSource) sseSource.close()
+ sseSource = new EventSource(`${HUB_URL}/sse?topics=${tokens.map(t => 'conv:' + t).concat(['conversations']).join(',')}`)
+ sseSource.addEventListener('conv-message', e => {
+ try {
+ const data = JSON.parse(e.data)
+ if (activeDiscussion.value && data.message) {
+ const msg = { ...data.message, convToken: data.token }
+ if (!activeDiscussion.value.messages.find(m => m.id === msg.id)) activeDiscussion.value.messages.push(msg)
+ }
+ updateListFromMessage(data)
+ } catch {}
+ })
+ sseSource.addEventListener('conv-closed', handleConvClosed)
+ sseSource.addEventListener('conv-deleted', () => fetchList())
+ }
+
+ function updateListFromMessage (data) {
+ const idx = conversations.value.findIndex(c => c.token === data.token)
+ if (idx >= 0) {
+ conversations.value[idx].lastMessage = data.message
+ conversations.value[idx].lastActivity = data.message.ts
+ conversations.value[idx].messageCount++
+ } else fetchList()
+ }
+
+ function handleConvClosed (e) {
+ try {
+ const data = JSON.parse(e.data)
+ const idx = conversations.value.findIndex(c => c.token === data.token)
+ if (idx >= 0) conversations.value[idx].status = 'closed'
+ if (activeConv.value && data.token === activeToken.value) activeConv.value.status = 'closed'
+ } catch {}
+ }
+
+ function connectSSE (token) {
+ if (sseSource) sseSource.close()
+ sseSource = new EventSource(`${HUB_URL}/sse?topics=conv:${token},conversations`)
+ sseSource.addEventListener('conv-message', e => {
+ try {
+ const data = JSON.parse(e.data)
+ if (activeConv.value && data.token === activeToken.value && data.message) {
+ if (!activeConv.value.messages.find(m => m.id === data.message.id)) activeConv.value.messages.push(data.message)
+ }
+ updateListFromMessage(data)
+ } catch {}
+ })
+ sseSource.addEventListener('conv-closed', handleConvClosed)
+ sseSource.addEventListener('conv-deleted', () => fetchList())
+ }
+
+ function connectGlobalSSE () {
+ if (sseSource) sseSource.close()
+ sseSource = new EventSource(`${HUB_URL}/sse?topics=conversations`)
+ sseSource.addEventListener('conv-message', e => { try { updateListFromMessage(JSON.parse(e.data)) } catch {} })
+ sseSource.addEventListener('conv-deleted', () => fetchList())
+ }
+
+ async function sendMessage (token, text) {
+ const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text }) })
+ if (!res.ok) throw new Error('Failed to send')
+ return res.json()
+ }
+
+ async function startConversation ({ phone, customer, customerName, subject }) {
+ const res = await fetch(`${HUB_URL}/conversations`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ phone, customer, customerName, subject }) })
+ if (!res.ok) throw new Error('Failed to create conversation')
+ const data = await res.json(); await fetchList(); return data
+ }
+
+ async function closeConversation (token) {
+ await fetch(`${HUB_URL}/conversations/${token}/close`, { method: 'POST', headers: agentHeaders() })
+ if (activeConv.value && activeToken.value === token) activeConv.value.status = 'closed'
+ const idx = conversations.value.findIndex(c => c.token === token)
+ if (idx >= 0) conversations.value[idx].status = 'closed'
+ }
+
+ async function deleteDiscussion (disc) {
+ const res = await fetch(`${HUB_URL}/conversations/discussion`, {
+ method: 'DELETE', headers: agentHeaders(),
+ body: JSON.stringify({ phone: disc.phone, date: disc.id.includes(':active') ? 'active' : disc.date }),
+ })
+ if (!res.ok) throw new Error('Failed to delete')
+ clearActiveIfMatch(disc); await fetchList()
+ }
+
+ async function bulkDelete (discs) {
+ const res = await fetch(`${HUB_URL}/conversations/bulk`, {
+ method: 'DELETE', headers: agentHeaders(),
+ body: JSON.stringify({ discussions: discs.map(d => ({ tokens: d.conversations.map(c => c.token) })) }),
+ })
+ if (!res.ok) throw new Error('Failed to bulk delete')
+ const data = await res.json()
+ for (const d of discs) clearActiveIfMatch(d)
+ selectedIds.value.clear(); await fetchList(); return data
+ }
+
+ async function archiveDiscussion (disc) {
+ const res = await fetch(`${HUB_URL}/conversations/archive`, {
+ method: 'POST', headers: agentHeaders(),
+ body: JSON.stringify({ tokens: disc.conversations.map(c => c.token) }),
+ })
+ if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Archive failed') }
+ const data = await res.json(); clearActiveIfMatch(disc); await fetchList(); return data
+ }
+
+ async function bulkArchive (discs) {
+ const results = []
+ for (const d of discs) {
+ const res = await fetch(`${HUB_URL}/conversations/archive`, {
+ method: 'POST', headers: agentHeaders(),
+ body: JSON.stringify({ tokens: d.conversations.map(c => c.token) }),
+ })
+ if (res.ok) results.push(await res.json())
+ }
+ selectedIds.value.clear(); await fetchList(); return results
+ }
+
+ function clearActiveIfMatch (disc) {
+ if (activeDiscussion.value?.id === disc.id) { activeDiscussion.value = null; activeToken.value = null; activeConv.value = null }
+ }
+
+ function toggleSelect (discId) {
+ const s = new Set(selectedIds.value)
+ s.has(discId) ? s.delete(discId) : s.add(discId)
+ selectedIds.value = s
+ }
+
+ function selectAll () { selectedIds.value = new Set(discussions.value.map(d => d.id)) }
+ function clearSelection () { selectedIds.value = new Set() }
+
+ const selectedDiscussions = computed(() => discussions.value.filter(d => selectedIds.value.has(d.id)))
+
+ function disconnectSSE () { if (sseSource) { sseSource.close(); sseSource = null } }
+
+ function goBack () {
+ activeToken.value = null; activeConv.value = null; activeDiscussion.value = null
+ if (sseSource) sseSource.close()
+ connectGlobalSSE()
+ }
+
+ const activeCount = computed(() => discussions.value.filter(d => d.status === 'active').length)
+
+ return {
+ conversations, discussions, activeToken, activeDiscussion, activeConv,
+ loading, panelOpen, showAll, selectedIds, selectedDiscussions,
+ fetchList, openDiscussion, openConversation, sendMessage, startConversation,
+ closeConversation, deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive,
+ toggleSelect, selectAll, clearSelection,
+ goBack, disconnectSSE, connectGlobalSSE, activeCount, HUB_URL,
+ }
+}
diff --git a/apps/ops/src/composables/useDeviceStatus.js b/apps/ops/src/composables/useDeviceStatus.js
index b69514a..53a3d41 100644
--- a/apps/ops/src/composables/useDeviceStatus.js
+++ b/apps/ops/src/composables/useDeviceStatus.js
@@ -8,9 +8,17 @@ const HUB_URL = (window.location.hostname === 'localhost')
? 'http://localhost:3300'
: 'https://msg.gigafibre.ca'
-// Cache: serial → { data, ts }
-const cache = new Map()
+// Singleton state — survives component remounts so cached data shows instantly
+const cache = new Map() // serial → { data, ts }
+const hostsCache = new Map() // serial → { data, ts }
+const oltCache = new Map() // serial → { data, ts }
+const deviceMap = ref(new Map()) // serial → { ...summarizedDevice }
+const oltMap = ref(new Map()) // serial → { status, rxPowerOlt, ... }
+const loading = ref(false)
+const error = ref(null)
const CACHE_TTL = 60_000 // 1 min
+const HOSTS_TTL = 120_000 // 2 min
+const OLT_TTL = 60_000 // 1 min
/**
* Fetch live device info from GenieACS for a list of equipment objects.
@@ -18,45 +26,54 @@ const CACHE_TTL = 60_000 // 1 min
* Returns a reactive Map.
*/
export function useDeviceStatus () {
- const deviceMap = ref(new Map()) // serial → { ...summarizedDevice }
- const loading = ref(false)
- const error = ref(null)
async function fetchStatus (equipmentList) {
if (!equipmentList || !equipmentList.length) return
- loading.value = true
error.value = null
const now = Date.now()
const toFetch = []
const map = new Map(deviceMap.value)
+ // Immediately populate from cache so UI shows buffered data
for (const eq of equipmentList) {
const serial = eq.serial_number
if (!serial) continue
const cached = cache.get(serial)
- if (cached && (now - cached.ts) < CACHE_TTL) {
+ if (cached) {
+ // Always show cached data immediately (even if stale)
map.set(serial, cached.data)
- } else {
- toFetch.push(serial)
+ // Only skip re-fetch if cache is fresh
+ if ((now - cached.ts) < CACHE_TTL) continue
}
+ toFetch.push(serial)
}
+ // Apply cached data right away before network fetch
+ if (map.size > deviceMap.value.size) {
+ deviceMap.value = new Map(map)
+ }
+
+ if (!toFetch.length) return
+
// Batch lookups (GenieACS NBI doesn't support batch, so parallel individual)
- if (toFetch.length) {
- const results = await Promise.allSettled(
- toFetch.map(serial =>
- fetch(`${HUB_URL}/devices/lookup?serial=${encodeURIComponent(serial)}`)
- .then(r => r.ok ? r.json() : null)
- .then(data => ({ serial, data: Array.isArray(data) && data.length ? data[0] : null }))
- .catch(() => ({ serial, data: null }))
- )
+ loading.value = true
+ const results = await Promise.allSettled(
+ toFetch.map(serial =>
+ fetch(`${HUB_URL}/devices/lookup?serial=${encodeURIComponent(serial)}`)
+ .then(r => r.ok ? r.json() : null)
+ .then(data => ({ serial, data: Array.isArray(data) && data.length ? data[0] : null }))
+ .catch(() => ({ serial, data: null }))
)
- for (const r of results) {
- if (r.status === 'fulfilled' && r.value.data) {
- map.set(r.value.serial, r.value.data)
- cache.set(r.value.serial, { data: r.value.data, ts: now })
- }
+ )
+ for (const r of results) {
+ if (r.status === 'fulfilled' && r.value.data) {
+ // Merge fresh data on top of existing cached data (don't clear fields)
+ const existing = map.get(r.value.serial)
+ const fresh = r.value.data
+ const merged = existing ? { ...existing, ...fresh } : fresh
+ map.set(r.value.serial, merged)
+ cache.set(r.value.serial, { data: merged, ts: now })
}
}
@@ -68,11 +85,92 @@ export function useDeviceStatus () {
return deviceMap.value.get(serial) || null
}
+ /**
+ * Fetch OLT SNMP status for a serial number.
+ * Returns { status: 'online'|'offline', rxPowerOlt, distance, ... } or null.
+ */
+ async function fetchOltStatus (serial) {
+ if (!serial) return null
+ const now = Date.now()
+ const cached = oltCache.get(serial)
+ if (cached && (now - cached.ts) < OLT_TTL) return cached.data
+
+ try {
+ const res = await fetch(`${HUB_URL}/olt/onus?serial=${encodeURIComponent(serial)}`)
+ if (!res.ok) return cached?.data || null
+ const data = await res.json()
+ if (data && !data.error) {
+ oltCache.set(serial, { data, ts: now })
+ const map = new Map(oltMap.value)
+ map.set(serial, data)
+ oltMap.value = map
+ return data
+ }
+ } catch {}
+ return cached?.data || null
+ }
+
+ function getOltData (serial) {
+ return oltMap.value.get(serial) || null
+ }
+
function isOnline (serial) {
const d = getDevice(serial)
- if (!d || !d.lastInform) return null // unknown
- const age = Date.now() - new Date(d.lastInform).getTime()
- return age < 5 * 60 * 1000 // online if last inform < 5 min ago
+ const tr069Online = d && d.lastInform
+ ? (Date.now() - new Date(d.lastInform).getTime()) < 15 * 60 * 1000
+ : null
+
+ // Cross-reference OLT SNMP — ground truth for fiber connectivity
+ const olt = getOltData(serial)
+ const oltOnline = olt ? olt.status === 'online' : null
+
+ // OLT is authoritative: if OLT says online, device has internet regardless of TR-069
+ if (oltOnline === true) return true
+ if (oltOnline === false) return false
+ // No OLT data available — fall back to TR-069 only
+ return tr069Online
+ }
+
+ /**
+ * Combined status with source info for UI display.
+ * Returns { online: bool|null, source: 'both'|'olt'|'tr069'|'unknown', label: string, detail: string }
+ */
+ function combinedStatus (serial) {
+ const d = getDevice(serial)
+ const tr069Online = d && d.lastInform
+ ? (Date.now() - new Date(d.lastInform).getTime()) < 15 * 60 * 1000
+ : null
+ const olt = getOltData(serial)
+ const oltOnline = olt ? olt.status === 'online' : null
+
+ // Both agree online
+ if (tr069Online === true && oltOnline === true) {
+ return { online: true, source: 'both', label: 'En ligne', detail: 'TR-069 + Fibre OK' }
+ }
+ // OLT says online, TR-069 stale — device is online, management channel stale
+ if (oltOnline === true && tr069Online !== true) {
+ return { online: true, source: 'olt', label: 'En ligne', detail: 'Fibre OK · TR-069 inactif' }
+ }
+ // Both agree offline
+ if (tr069Online === false && oltOnline === false) {
+ return { online: false, source: 'both', label: 'Hors ligne', detail: 'TR-069 + Fibre hors ligne' }
+ }
+ // OLT says offline, TR-069 unknown
+ if (oltOnline === false && tr069Online === null) {
+ return { online: false, source: 'olt', label: 'Hors ligne', detail: 'Fibre hors ligne' }
+ }
+ // OLT says offline, TR-069 says online (unlikely but possible during transition)
+ if (oltOnline === false && tr069Online === true) {
+ return { online: false, source: 'olt', label: 'Hors ligne', detail: 'Fibre coupée · TR-069 résiduel' }
+ }
+ // No OLT data, TR-069 only
+ if (tr069Online === true) {
+ return { online: true, source: 'tr069', label: 'En ligne', detail: 'TR-069 actif' }
+ }
+ if (tr069Online === false) {
+ return { online: false, source: 'tr069', label: 'Hors ligne', detail: 'TR-069 inactif · OLT non vérifié' }
+ }
+ return { online: null, source: 'unknown', label: 'Inconnu', detail: 'Aucune donnée' }
}
function signalQuality (serial) {
@@ -128,27 +226,41 @@ export function useDeviceStatus () {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Refresh failed')
}
- // Invalidate cache so next fetch gets fresh data
- cache.delete(serial)
+ // Mark cache stale so next fetch refreshes, but keep data for instant display
+ const cached = cache.get(serial)
+ if (cached) cached.ts = 0
return res.json()
}
async function fetchHosts (serial, refresh = false) {
+ // Return cached hosts instantly if available and not forcing refresh
+ const cached = hostsCache.get(serial)
+ if (cached && !refresh && (Date.now() - cached.ts) < HOSTS_TTL) {
+ return cached.data
+ }
+
const d = getDevice(serial)
- if (!d) return null
+ if (!d) return cached?.data || null // return stale cache if device not resolved yet
+
const url = `${HUB_URL}/devices/${encodeURIComponent(d._id)}/hosts${refresh ? '?refresh' : ''}`
const res = await fetch(url)
- if (!res.ok) return null
- return res.json()
+ if (!res.ok) return cached?.data || null
+ const data = await res.json()
+ hostsCache.set(serial, { data, ts: Date.now() })
+ return data
}
return {
deviceMap: readonly(deviceMap),
+ oltMap: readonly(oltMap),
loading: readonly(loading),
error: readonly(error),
fetchStatus,
+ fetchOltStatus,
getDevice,
+ getOltData,
isOnline,
+ combinedStatus,
signalQuality,
rebootDevice,
refreshDeviceParams,
diff --git a/apps/ops/src/composables/useGpsTracking.js b/apps/ops/src/composables/useGpsTracking.js
index 51e398a..499e7dd 100644
--- a/apps/ops/src/composables/useGpsTracking.js
+++ b/apps/ops/src/composables/useGpsTracking.js
@@ -1,5 +1,5 @@
import { ref } from 'vue'
-import { fetchDevices, fetchPositions, createTraccarSession } from 'src/api/traccar'
+import { fetchDevices, fetchPositions } from 'src/api/traccar'
let __gpsStarted = false
let __gpsInterval = null
@@ -42,6 +42,7 @@ export function useGpsTracking (technicians) {
if (!deviceIds.length) return
const positions = await fetchPositions(deviceIds)
_applyPositions(positions)
+ // Mark techs with no position as offline
Object.values(_techsByDevice).forEach(t => {
if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false
})
@@ -49,54 +50,16 @@ export function useGpsTracking (technicians) {
finally { __gpsPolling = false }
}
- let __ws = null
- let __wsBackoff = 1000
-
- function _connectWs () {
- if (__ws) return
- const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
- const url = proto + '//' + window.location.host + '/traccar/api/socket'
- try { __ws = new WebSocket(url) } catch { return }
- __ws.onopen = () => {
- __wsBackoff = 1000
- if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
- }
- __ws.onmessage = (e) => {
- try {
- const data = JSON.parse(e.data)
- if (data.positions?.length) {
- _buildTechDeviceMap()
- _applyPositions(data.positions)
- }
- } catch { /* parse error */ }
- }
- __ws.onerror = () => {}
- __ws.onclose = () => {
- __ws = null
- if (!__gpsStarted) return
- if (!__gpsInterval) {
- __gpsInterval = setInterval(pollGps, 30000)
- }
- setTimeout(_connectWs, __wsBackoff)
- __wsBackoff = Math.min(__wsBackoff * 2, 60000)
- }
- }
-
async function startGpsTracking () {
if (__gpsStarted) return
__gpsStarted = true
await pollGps()
- const sessionOk = await createTraccarSession()
- if (sessionOk) {
- _connectWs()
- } else {
- __gpsInterval = setInterval(pollGps, 30000)
- }
+ __gpsInterval = setInterval(pollGps, 30000)
}
function stopGpsTracking () {
+ __gpsStarted = false
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
- if (__ws) { const ws = __ws; __ws = null; ws.onclose = null; ws.close() }
}
return { traccarDevices, pollGps, startGpsTracking, stopGpsTracking }
diff --git a/apps/ops/src/composables/useHelpers.js b/apps/ops/src/composables/useHelpers.js
index b916161..f00045b 100644
--- a/apps/ops/src/composables/useHelpers.js
+++ b/apps/ops/src/composables/useHelpers.js
@@ -98,13 +98,78 @@ export function sortJobsByTime (jobs) {
})
}
+// ── ERPNext ↔ internal status mapping ──
+// ERPNext stores French labels; frontend uses internal codes
+export const ERP_STATUS_TO_INTERNAL = {
+ 'Disponible': 'available',
+ 'En route': 'en-route',
+ 'En pause': 'off',
+ 'Hors ligne': 'offline',
+ 'Inactif': 'inactive',
+}
+export const INTERNAL_STATUS_TO_ERP = Object.fromEntries(
+ Object.entries(ERP_STATUS_TO_INTERNAL).map(([k, v]) => [v, k])
+)
+export function normalizeStatus (erpStatus) {
+ return ERP_STATUS_TO_INTERNAL[erpStatus] || erpStatus || 'available'
+}
+export function toErpStatus (internalStatus) {
+ return INTERNAL_STATUS_TO_ERP[internalStatus] || internalStatus || 'Disponible'
+}
+
+// ── Weekly schedule helpers ──
+const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
+export const DAY_LABELS = { mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }
+export const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
+export const DEFAULT_WEEKLY_SCHEDULE = {
+ mon: { start: '08:00', end: '16:00' }, tue: { start: '08:00', end: '16:00' },
+ wed: { start: '08:00', end: '16:00' }, thu: { start: '08:00', end: '16:00' },
+ fri: { start: '08:00', end: '16:00' }, sat: null, sun: null,
+}
+export const SCHEDULE_PRESETS = [
+ { key: 'standard', label: '5×8h (lun-ven)', schedule: { ...DEFAULT_WEEKLY_SCHEDULE } },
+ { key: '4x10', label: '4×10h (lun-jeu)', schedule: {
+ mon: { start: '07:00', end: '17:00' }, tue: { start: '07:00', end: '17:00' },
+ wed: { start: '07:00', end: '17:00' }, thu: { start: '07:00', end: '17:00' },
+ fri: null, sat: null, sun: null,
+ }},
+ { key: '3x12', label: '3×12h (lun-mer)', schedule: {
+ mon: { start: '06:00', end: '18:00' }, tue: { start: '06:00', end: '18:00' },
+ wed: { start: '06:00', end: '18:00' }, thu: null, fri: null, sat: null, sun: null,
+ }},
+]
+export function parseWeeklySchedule (jsonStr) {
+ if (!jsonStr) return { ...DEFAULT_WEEKLY_SCHEDULE }
+ try {
+ const parsed = typeof jsonStr === 'string' ? JSON.parse(jsonStr) : jsonStr
+ if (typeof parsed !== 'object' || parsed === null) return { ...DEFAULT_WEEKLY_SCHEDULE }
+ return parsed
+ } catch { return { ...DEFAULT_WEEKLY_SCHEDULE } }
+}
+export function techDaySchedule (tech, dateStr) {
+ const d = new Date(dateStr + 'T12:00:00')
+ const key = DAY_KEYS[d.getDay()]
+ const sched = tech.weeklySchedule || DEFAULT_WEEKLY_SCHEDULE
+ const day = sched[key]
+ if (!day) return null
+ const startH = timeToH(day.start || '08:00')
+ const endH = timeToH(day.end || '16:00')
+ return { start: day.start, end: day.end, startH, endH, hours: endH - startH }
+}
+export function techDayCapacityH (tech, dateStr) {
+ const s = techDaySchedule(tech, dateStr)
+ return s ? s.hours : 0
+}
+
// Status helpers
export const STATUS_MAP = {
'available': { cls:'st-available', label:'Disponible' },
'en-route': { cls:'st-enroute', label:'En route' },
'busy': { cls:'st-busy', label:'En cours' },
'in progress': { cls:'st-busy', label:'En cours' },
- 'off': { cls:'st-off', label:'Hors shift' },
+ 'off': { cls:'st-off', label:'En pause' },
+ 'offline': { cls:'st-off', label:'Hors ligne' },
+ 'inactive': { cls:'st-inactive', label:'Inactif' },
}
export function stOf (t) { return STATUS_MAP[(t.status||'').toLowerCase()] || STATUS_MAP['available'] }
diff --git a/apps/ops/src/composables/usePermissions.js b/apps/ops/src/composables/usePermissions.js
new file mode 100644
index 0000000..9ab2aa1
--- /dev/null
+++ b/apps/ops/src/composables/usePermissions.js
@@ -0,0 +1,98 @@
+import { ref, computed } from 'vue'
+
+const HUB_URL = (window.location.hostname === 'localhost')
+ ? 'http://localhost:3300'
+ : 'https://msg.gigafibre.ca'
+
+// Singleton state — shared across all components
+const permissions = ref(null) // { email, username, name, groups, is_superuser, capabilities, overrides }
+const loading = ref(false)
+const error = ref(null)
+
+let fetchPromise = null // Prevent duplicate fetches
+
+/**
+ * Fetch effective permissions for the current user from targo-hub.
+ * Reads X-Authentik-Email header (injected by Traefik).
+ * @param {string} email — user email (from Authentik header or session)
+ */
+async function loadPermissions (email) {
+ if (!email) return
+ if (fetchPromise) return fetchPromise
+
+ loading.value = true
+ error.value = null
+
+ fetchPromise = fetch(`${HUB_URL}/auth/permissions?email=${encodeURIComponent(email)}`)
+ .then(r => {
+ if (!r.ok) throw new Error('Permission fetch failed: ' + r.status)
+ return r.json()
+ })
+ .then(data => {
+ permissions.value = data
+ })
+ .catch(e => {
+ console.error('[usePermissions]', e.message)
+ error.value = e.message
+ // Fallback: allow everything for superusers if fetch fails
+ permissions.value = null
+ })
+ .finally(() => {
+ loading.value = false
+ fetchPromise = null
+ })
+
+ return fetchPromise
+}
+
+/**
+ * Check if the current user has a specific capability.
+ * Superusers always return true.
+ * Returns false if permissions haven't loaded yet (safe default).
+ */
+function can (capability) {
+ if (!permissions.value) return false
+ if (permissions.value.is_superuser) return true
+ return permissions.value.capabilities?.[capability] === true
+}
+
+/**
+ * Check if the user has ANY of the listed capabilities.
+ */
+function canAny (...capabilities) {
+ return capabilities.some(c => can(c))
+}
+
+/**
+ * Check if the user belongs to a specific group.
+ */
+function hasGroup (groupName) {
+ return permissions.value?.groups?.includes(groupName) || false
+}
+
+// Computed helpers
+const userGroups = computed(() => permissions.value?.groups || [])
+const userName = computed(() => permissions.value?.name || '')
+const userEmail = computed(() => permissions.value?.email || '')
+const isSuperuser = computed(() => permissions.value?.is_superuser || false)
+const isAdmin = computed(() => hasGroup('admin') || isSuperuser.value)
+const isLoaded = computed(() => permissions.value !== null)
+
+export function usePermissions () {
+ return {
+ permissions,
+ loading,
+ error,
+ loadPermissions,
+ can,
+ canAny,
+ hasGroup,
+ userGroups,
+ userName,
+ userEmail,
+ isSuperuser,
+ isAdmin,
+ isLoaded,
+ HUB_URL,
+ }
+}
diff --git a/apps/ops/src/composables/useResourceFilter.js b/apps/ops/src/composables/useResourceFilter.js
index b726f37..d2849bc 100644
--- a/apps/ops/src/composables/useResourceFilter.js
+++ b/apps/ops/src/composables/useResourceFilter.js
@@ -3,7 +3,9 @@ import { ref, computed, watch } from 'vue'
export function useResourceFilter (store) {
const selectedResIds = ref(JSON.parse(localStorage.getItem('sbv2-resIds') || '[]'))
const filterStatus = ref(localStorage.getItem('sbv2-filterStatus') || '')
+ const filterGroup = ref(localStorage.getItem('sbv2-filterGroup') || '')
const filterTags = ref(JSON.parse(localStorage.getItem('sbv2-filterTags') || '[]'))
+ const filterResourceType = ref(localStorage.getItem('sbv2-filterResType') || '') // '' | 'human' | 'material'
const searchQuery = ref('')
const techSort = ref(localStorage.getItem('sbv2-techSort') || 'default')
const manualOrder = ref(JSON.parse(localStorage.getItem('sbv2-techOrder') || '[]'))
@@ -13,23 +15,77 @@ export function useResourceFilter (store) {
watch(selectedResIds, v => localStorage.setItem('sbv2-resIds', JSON.stringify(v)), { deep: true })
watch(filterStatus, v => localStorage.setItem('sbv2-filterStatus', v))
+ watch(filterGroup, v => localStorage.setItem('sbv2-filterGroup', v))
+ watch(filterResourceType, v => localStorage.setItem('sbv2-filterResType', v))
watch(techSort, v => localStorage.setItem('sbv2-techSort', v))
+ // Unique groups extracted from technicians
+ const availableGroups = computed(() => {
+ const groups = new Set()
+ for (const t of store.technicians) {
+ if (t.group) groups.add(t.group)
+ }
+ return [...groups].sort()
+ })
+
+ // Resource categories for material resources
+ const availableCategories = computed(() => {
+ const cats = new Set()
+ for (const t of store.technicians) {
+ if (t.resourceCategory) cats.add(t.resourceCategory)
+ }
+ return [...cats].sort()
+ })
+
+ // Count by type
+ const humanCount = computed(() => store.technicians.filter(t => t.resourceType !== 'material').length)
+ const materialCount = computed(() => store.technicians.filter(t => t.resourceType === 'material').length)
+
+ const showInactive = ref(false)
+
const filteredResources = computed(() => {
let list = store.technicians
+ // Hide permanently inactive techs unless explicitly showing them or filtering for them
+ // Temporarily absent (off) techs stay visible so absence blocks render on the timeline
+ if (!showInactive.value && filterStatus.value !== 'inactive') list = list.filter(t => t.status !== 'inactive')
+ if (filterResourceType.value) list = list.filter(t => (t.resourceType || 'human') === filterResourceType.value)
if (searchQuery.value) { const q = searchQuery.value.toLowerCase(); list = list.filter(t => t.fullName.toLowerCase().includes(q)) }
if (filterStatus.value) list = list.filter(t => (t.status||'available') === filterStatus.value)
+ if (filterGroup.value) list = list.filter(t => t.group === filterGroup.value)
if (selectedResIds.value.length) list = list.filter(t => selectedResIds.value.includes(t.id))
if (filterTags.value.length) list = list.filter(t => filterTags.value.every(ft => (t.tags||[]).includes(ft)))
- if (techSort.value === 'alpha') {
- list = [...list].sort((a, b) => a.fullName.split(' ').pop().toLowerCase().localeCompare(b.fullName.split(' ').pop().toLowerCase()))
- } else if (techSort.value === 'manual' && manualOrder.value.length) {
- const order = manualOrder.value
- list = [...list].sort((a, b) => (order.indexOf(a.id) === -1 ? 999 : order.indexOf(a.id)) - (order.indexOf(b.id) === -1 ? 999 : order.indexOf(b.id)))
- }
+ // Sort: humans first, then material; within each, apply chosen sort
+ list = [...list].sort((a, b) => {
+ const aType = a.resourceType === 'material' ? 1 : 0
+ const bType = b.resourceType === 'material' ? 1 : 0
+ if (aType !== bType) return aType - bType
+ if (techSort.value === 'alpha') return a.fullName.split(' ').pop().toLowerCase().localeCompare(b.fullName.split(' ').pop().toLowerCase())
+ if (techSort.value === 'manual' && manualOrder.value.length) {
+ const order = manualOrder.value
+ return (order.indexOf(a.id) === -1 ? 999 : order.indexOf(a.id)) - (order.indexOf(b.id) === -1 ? 999 : order.indexOf(b.id))
+ }
+ return 0
+ })
return list
})
+ // Resources grouped by tech_group for visual grouping
+ const groupedResources = computed(() => {
+ const groups = new Map()
+ for (const t of filteredResources.value) {
+ const g = t.group || ''
+ if (!groups.has(g)) groups.set(g, [])
+ groups.get(g).push(t)
+ }
+ // Sort: named groups first (alphabetically), then ungrouped last
+ const sorted = [...groups.entries()].sort((a, b) => {
+ if (!a[0] && b[0]) return 1
+ if (a[0] && !b[0]) return -1
+ return a[0].localeCompare(b[0])
+ })
+ return sorted.map(([name, techs]) => ({ name, label: name || 'Non assigné', techs }))
+ })
+
function onTechReorderStart (e, tech) {
dragReorderTech.value = tech
e.dataTransfer.effectAllowed = 'move'
@@ -52,11 +108,15 @@ export function useResourceFilter (store) {
const idx = tempSelectedIds.value.indexOf(id)
if (idx >= 0) tempSelectedIds.value.splice(idx, 1); else tempSelectedIds.value.push(id)
}
- function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; searchQuery.value = ''; filterTags.value = []; localStorage.removeItem('sbv2-filterTags') }
+ function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; filterGroup.value = ''; filterResourceType.value = ''; searchQuery.value = ''; filterTags.value = []; showInactive.value = false; localStorage.removeItem('sbv2-filterTags'); localStorage.removeItem('sbv2-filterGroup'); localStorage.removeItem('sbv2-filterResType') }
+
+ // Count of inactive techs (for UI indicator)
+ const inactiveCount = computed(() => store.technicians.filter(t => !t.active).length)
return {
- selectedResIds, filterStatus, filterTags, searchQuery, techSort, manualOrder,
- filteredResources, resSelectorOpen, tempSelectedIds, dragReorderTech,
+ selectedResIds, filterStatus, filterGroup, filterResourceType, filterTags, searchQuery, techSort, manualOrder,
+ showInactive, inactiveCount, humanCount, materialCount, availableCategories,
+ filteredResources, groupedResources, availableGroups, resSelectorOpen, tempSelectedIds, dragReorderTech,
openResSelector, applyResSelector, toggleTempRes, clearFilters,
onTechReorderStart, onTechReorderDrop,
}
diff --git a/apps/ops/src/composables/useScanner.js b/apps/ops/src/composables/useScanner.js
new file mode 100644
index 0000000..00976c2
--- /dev/null
+++ b/apps/ops/src/composables/useScanner.js
@@ -0,0 +1,123 @@
+import { ref } from 'vue'
+
+const HUB_BASE = window.location.hostname === 'localhost'
+ ? 'http://localhost:3300'
+ : 'https://msg.gigafibre.ca'
+
+export function useScanner () {
+ const barcodes = ref([])
+ const scanning = ref(false)
+ const error = ref(null)
+ const lastPhoto = ref(null)
+
+ async function processPhoto (file) {
+ if (!file) return []
+ error.value = null
+ scanning.value = true
+ const found = []
+ try {
+ const thumbUrl = await resizeImage(file, 400)
+ lastPhoto.value = thumbUrl
+ const aiImage = await resizeImage(file, 1600, 0.92)
+ const res = await fetch(`${HUB_BASE}/vision/barcodes`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: aiImage }),
+ })
+ if (!res.ok) throw new Error('Vision scan failed: ' + res.status)
+ const data = await res.json()
+ const existing = new Set(barcodes.value.map(b => b.value))
+ for (const code of (data.barcodes || [])) {
+ if (barcodes.value.length >= 5) break
+ if (!existing.has(code)) {
+ existing.add(code)
+ barcodes.value.push({ value: code })
+ found.push(code)
+ }
+ }
+ if (!found.length) error.value = 'Aucun code detecte — rapprochez-vous ou ameliorez la mise au point'
+ } catch (e) {
+ error.value = e.message || 'Erreur'
+ } finally {
+ scanning.value = false
+ }
+ return found
+ }
+
+ /**
+ * Smart equipment label scan — returns structured fields
+ * { brand, model, serial_number, mac_address, gpon_sn, hw_version, equipment_type, barcodes }
+ */
+ async function scanEquipmentLabel (file) {
+ if (!file) return null
+ error.value = null
+ scanning.value = true
+ try {
+ const thumbUrl = await resizeImage(file, 400)
+ lastPhoto.value = thumbUrl
+ const aiImage = await resizeImage(file, 1600, 0.92)
+ const res = await fetch(`${HUB_BASE}/vision/equipment`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: aiImage }),
+ })
+ if (!res.ok) throw new Error('Vision scan failed: ' + res.status)
+ const data = await res.json()
+ // Also populate barcodes list for display
+ if (data.barcodes?.length) {
+ const existing = new Set(barcodes.value.map(b => b.value))
+ for (const code of data.barcodes) {
+ if (barcodes.value.length >= 5) break
+ if (!existing.has(code)) {
+ existing.add(code)
+ barcodes.value.push({ value: code })
+ }
+ }
+ }
+ if (data.serial_number) {
+ const existing = new Set(barcodes.value.map(b => b.value))
+ if (!existing.has(data.serial_number)) {
+ barcodes.value.push({ value: data.serial_number })
+ }
+ }
+ if (!data.serial_number && !data.barcodes?.length) {
+ error.value = 'Aucun identifiant detecte — rapprochez-vous ou ameliorez la mise au point'
+ }
+ return data
+ } catch (e) {
+ error.value = e.message || 'Erreur'
+ return null
+ } finally {
+ scanning.value = false
+ }
+ }
+
+ function resizeImage (file, maxDim, quality = 0.85) {
+ return new Promise((resolve, reject) => {
+ const img = new Image()
+ img.onload = () => {
+ let { width, height } = img
+ if (width > maxDim || height > maxDim) {
+ const ratio = Math.min(maxDim / width, maxDim / height)
+ width = Math.round(width * ratio)
+ height = Math.round(height * ratio)
+ }
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+ canvas.getContext('2d').drawImage(img, 0, 0, width, height)
+ resolve(canvas.toDataURL('image/jpeg', quality))
+ }
+ img.onerror = reject
+ img.src = URL.createObjectURL(file)
+ })
+ }
+
+ function clearBarcodes () {
+ barcodes.value = []
+ error.value = null
+ lastPhoto.value = null
+ }
+
+ return { barcodes, scanning, error, lastPhoto, processPhoto, scanEquipmentLabel, clearBarcodes }
+}
diff --git a/apps/ops/src/composables/useScheduler.js b/apps/ops/src/composables/useScheduler.js
index d872f59..ede924c 100644
--- a/apps/ops/src/composables/useScheduler.js
+++ b/apps/ops/src/composables/useScheduler.js
@@ -1,6 +1,7 @@
// ── Scheduling logic: timeline computation, route cache, job placement ───────
import { ref, computed } from 'vue'
-import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate } from './useHelpers'
+import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate, techDaySchedule, techDayCapacityH } from './useHelpers'
+import { ABSENCE_REASONS } from './useTechManagement'
export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) {
const H_START = 7
@@ -24,7 +25,8 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
const cacheKey = `${leadTech.id}||${dayStr}`
const legMins = routeLegs.value[cacheKey]
const hasHome = !!(leadTech.coords?.[0] && leadTech.coords?.[1])
- let cursor = 8, result = job.startHour ?? 8
+ const _parentSched = techDaySchedule(leadTech, dayStr)
+ let cursor = _parentSched ? _parentSched.startH : 8, result = job.startHour ?? cursor
leadJobs.forEach((j, idx) => {
const showTravel = idx > 0 || (idx === 0 && hasHome)
if (showTravel) {
@@ -64,9 +66,54 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
return sortJobsByTime([...primary, ...assists])
}
+ // ── Absence / schedule-off segments for a tech on a given date ───────────────
+ function absenceSegmentsForDate (tech, dateStr) {
+ const segs = []
+
+ // 1. Explicit absence (vacation, sick, etc.)
+ if (tech.status === 'off' && tech.absenceFrom) {
+ const from = tech.absenceFrom
+ const until = tech.absenceUntil || from
+ if (dateStr >= from && dateStr <= until) {
+ const startH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : H_START
+ const endH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : H_END
+ const reasonObj = ABSENCE_REASONS.find(r => r.value === tech.absenceReason) || { label: 'Absent', icon: '⏸' }
+ const left = (startH - H_START) * pxPerHr.value
+ const width = (endH - startH) * pxPerHr.value
+ segs.push({
+ type: 'absence', startH, endH,
+ reason: tech.absenceReason, reasonLabel: reasonObj.label, reasonIcon: reasonObj.icon,
+ from: tech.absenceFrom, until: tech.absenceUntil, techId: tech.id,
+ style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' },
+ job: { id: `absence-${tech.id}-${dateStr}` },
+ })
+ return segs // absence covers the day — skip schedule check
+ }
+ }
+
+ // 2. Weekly schedule off-day (regular day off like Fridays for 4×10 schedule)
+ const daySched = techDaySchedule(tech, dateStr)
+ if (!daySched) {
+ const left = 0
+ const width = (H_END - H_START) * pxPerHr.value
+ segs.push({
+ type: 'absence', startH: H_START, endH: H_END,
+ reason: 'day_off', reasonLabel: 'Jour de repos', reasonIcon: '📅',
+ from: null, until: null, techId: tech.id,
+ style: { left: left + 'px', width: Math.max(18, width) + 'px', top: '4px', bottom: '4px', position: 'absolute' },
+ job: { id: `schedoff-${tech.id}-${dateStr}` },
+ })
+ }
+
+ return segs
+ }
+
// ── Day view: schedule blocks with pinned anchors + auto-flow ──────────────
function techDayJobsWithTravel (tech) {
+ _checkCacheInvalidation()
const dayStr = localDateStr(periodStart.value)
+ const segKey = `${tech.id}||${dayStr}||${tech.queue.length}||${(tech.assistJobs||[]).length}`
+ if (_daySegmentsCache.has(segKey)) return _daySegmentsCache.get(segKey)
const cacheKey = `${tech.id}||${dayStr}`
const legMins = routeLegs.value[cacheKey]
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1])
@@ -84,9 +131,13 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
else flowEntries.push(entry)
})
+ // Absence blocks act as occupied time (prevent auto-placing jobs there)
+ const absSegs = absenceSegmentsForDate(tech, dayStr)
+ const absOccupied = absSegs.map(s => ({ start: s.startH, end: s.endH }))
+
const pinnedAnchors = flowEntries.filter(e => e.isPinned).map(e => ({ start: e.pinH, end: e.pinH + e.dur }))
const placed = []
- const occupied = pinnedAnchors.map(a => ({ ...a }))
+ const occupied = [...pinnedAnchors.map(a => ({ ...a })), ...absOccupied]
const sortedFlow = [...flowEntries].sort((a, b) => {
if (a.isPinned && b.isPinned) return a.pinH - b.pinH
if (a.isPinned) return -1
@@ -96,7 +147,8 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
sortedFlow.filter(e => e.isPinned).forEach(e => placed.push({ entry: e, startH: e.pinH }))
- let cursor = 8, flowIdx = 0
+ const daySched = techDaySchedule(tech, dayStr)
+ let cursor = daySched ? daySched.startH : 8, flowIdx = 0
sortedFlow.filter(e => !e.isPinned).forEach(e => {
const legIdx = hasHome ? flowIdx : flowIdx - 1
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
@@ -115,18 +167,26 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
flowIdx++
})
+ // Inject absence blocks as pseudo-placed entries so travel chains around them
+ absSegs.forEach(s => {
+ placed.push({ entry: { job: s.job, dur: s.endH - s.startH, isAssist: false, isPinned: true, pinH: s.startH, _isAbsence: true, _absSeg: s }, startH: s.startH })
+ })
+
placed.sort((a, b) => a.startH - b.startH)
const result = []
let prevEndH = null
- placed.forEach((p, pIdx) => {
+ let legCounter = 0
+ placed.forEach((p) => {
const { entry, startH } = p
- const { job, dur, isAssist, isPinned } = entry
- const realJob = isAssist ? job._parentJob : job
+ const { job, dur, isAssist, isPinned, _isAbsence, _absSeg } = entry
+
+ // Travel gap between previous block end and this block start
const travelStart = prevEndH ?? (hasHome ? 8 : null)
- if (travelStart != null && startH > travelStart + 0.01) {
+ if (travelStart != null && startH > travelStart + 0.01 && !_isAbsence) {
const gapH = startH - travelStart
- const legIdx = hasHome ? pIdx : pIdx - 1
+ const realJob = isAssist ? job._parentJob : job
+ const legIdx = hasHome ? legCounter : legCounter - 1
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
const fromRoute = routeMin != null
result.push({
@@ -134,16 +194,24 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
style: { left: (travelStart - H_START) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' },
color: jobColorFn(realJob),
})
+ legCounter++
+ }
+
+ if (_isAbsence) {
+ // Render as absence block
+ result.push(_absSeg)
+ } else {
+ const realJob = isAssist ? job._parentJob : job
+ const jLeft = (startH - H_START) * pxPerHr.value
+ const jWidth = Math.max(18, dur * pxPerHr.value)
+ result.push({
+ type: isAssist ? 'assist' : 'job', job: realJob,
+ pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist,
+ assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null,
+ assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null,
+ style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' },
+ })
}
- const jLeft = (startH - H_START) * pxPerHr.value
- const jWidth = Math.max(18, dur * pxPerHr.value)
- result.push({
- type: isAssist ? 'assist' : 'job', job: realJob,
- pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist,
- assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null,
- assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null,
- style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' },
- })
prevEndH = startH + dur
})
@@ -157,6 +225,7 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
})
})
+ _daySegmentsCache.set(segKey, result)
return result
}
@@ -168,11 +237,31 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
const assists = (tech.assistJobs || [])
.filter(j => jobSpansDate(j, ds) && j.assistants.find(a => a.techId === tech.id)?.pinned)
.map(j => ({ ...j, _isAssistChip: true, _assistDur: j.assistants.find(a => a.techId === tech.id)?.duration || j.duration }))
- return { day: d, dateStr: ds, jobs: [...primary, ...assists] }
+ const absSegs = absenceSegmentsForDate(tech, ds)
+ return { day: d, dateStr: ds, jobs: [...primary, ...assists], absent: absSegs.length > 0, absenceInfo: absSegs[0] || null }
})
}
+ // ── Memoization caches (cleared on reactive dependency changes) ──────────
+ const _periodLoadCache = new Map()
+ const _periodCapCache = new Map()
+ const _daySegmentsCache = new Map()
+
+ // Invalidate caches when period/view changes
+ let _lastCacheKey = ''
+ function _checkCacheInvalidation () {
+ const key = `${currentView.value}||${periodStart.value}||${dayColumns.value.length}||${store.jobs.length}`
+ if (key !== _lastCacheKey) {
+ _lastCacheKey = key
+ _periodLoadCache.clear()
+ _periodCapCache.clear()
+ _daySegmentsCache.clear()
+ }
+ }
+
function periodLoadH (tech) {
+ _checkCacheInvalidation()
+ if (_periodLoadCache.has(tech.id)) return _periodLoadCache.get(tech.id)
const dateSet = new Set(dayColumns.value.map(d => localDateStr(d)))
let total = tech.queue.reduce((sum, j) => {
const ds = getJobDate(j.id)
@@ -185,9 +274,36 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
if (a?.pinned) total += parseFloat(a?.duration || j.duration) || 0
}
})
+ // Include absence hours as occupied time
+ dayColumns.value.forEach(d => {
+ const ds = localDateStr(d)
+ const absSegs = absenceSegmentsForDate(tech, ds)
+ absSegs.forEach(s => { total += s.endH - s.startH })
+ })
+ _periodLoadCache.set(tech.id, total)
return total
}
+ // Per-tech capacity for the visible period (for utilization bar denominator)
+ function techPeriodCapacityH (tech) {
+ _checkCacheInvalidation()
+ if (_periodCapCache.has(tech.id)) return _periodCapCache.get(tech.id)
+ let total = 0
+ dayColumns.value.forEach(d => {
+ total += techDayCapacityH(tech, localDateStr(d))
+ })
+ const result = total || 8 // fallback to avoid /0
+ _periodCapCache.set(tech.id, result)
+ return result
+ }
+
+ // Per-tech end hour for current day (for capacity line position)
+ function techDayEndH (tech) {
+ const dayStr = localDateStr(periodStart.value)
+ const sched = techDaySchedule(tech, dayStr)
+ return sched ? sched.endH : 16
+ }
+
function techsActiveOnDay (dateStr, resources) {
return resources.filter(tech =>
tech.queue.some(j => jobSpansDate(j, dateStr)) ||
@@ -203,7 +319,8 @@ export function useScheduler (store, currentView, periodStart, periodDays, dayCo
return {
H_START, H_END, routeLegs, routeGeometry,
- techAllJobsForDate, techDayJobsWithTravel,
- techBookingsByDay, periodLoadH, techsActiveOnDay, dayJobCount,
+ techAllJobsForDate, techDayJobsWithTravel, absenceSegmentsForDate,
+ techBookingsByDay, periodLoadH, techPeriodCapacityH, techDayEndH,
+ techsActiveOnDay, dayJobCount,
}
}
diff --git a/apps/ops/src/composables/useTechManagement.js b/apps/ops/src/composables/useTechManagement.js
index 2dfea63..2d68767 100644
--- a/apps/ops/src/composables/useTechManagement.js
+++ b/apps/ops/src/composables/useTechManagement.js
@@ -1,5 +1,17 @@
import { ref } from 'vue'
import { updateTech } from 'src/api/dispatch'
+import { toErpStatus } from 'src/composables/useHelpers'
+
+const ABSENCE_REASONS = [
+ { value: 'vacation', label: 'Vacances', icon: '🏖️' },
+ { value: 'sick', label: 'Maladie', icon: '🤒' },
+ { value: 'personal', label: 'Personnel', icon: '🏠' },
+ { value: 'training', label: 'Formation', icon: '📚' },
+ { value: 'injury', label: 'Blessure', icon: '🩹' },
+ { value: 'other', label: 'Autre', icon: '📋' },
+]
+
+export { ABSENCE_REASONS }
export function useTechManagement (store, invalidateRoutes) {
const editingTech = ref(null)
@@ -8,6 +20,12 @@ export function useTechManagement (store, invalidateRoutes) {
const newTechDevice = ref('')
const addingTech = ref(false)
+ // Absence modal state
+ const absenceModalOpen = ref(false)
+ const absenceModalTech = ref(null)
+ const absenceForm = ref({ reason: 'vacation', from: '', until: '', jobAction: 'unassign' })
+ const absenceProcessing = ref(false)
+
async function saveTechField (tech, field, value) {
const trimmed = typeof value === 'string' ? value.trim() : value
if (field === 'full_name') {
@@ -15,15 +33,17 @@ export function useTechManagement (store, invalidateRoutes) {
tech.fullName = trimmed
} else if (field === 'status') {
tech.status = trimmed
+ tech.active = trimmed !== 'inactive' && trimmed !== 'off'
} else if (field === 'phone') {
if (trimmed === tech.phone) return
tech.phone = trimmed
}
- try { await updateTech(tech.name || tech.id, { [field]: trimmed }) }
+ const saveValue = field === 'status' ? toErpStatus(trimmed) : trimmed
+ try { await updateTech(tech.name || tech.id, { [field]: saveValue }) }
catch (_e) { /* save failed */ }
}
- async function addTech () {
+ async function addTech (extraFields = {}) {
const name = newTechName.value.trim()
if (!name || addingTech.value) return
addingTech.value = true
@@ -32,6 +52,7 @@ export function useTechManagement (store, invalidateRoutes) {
full_name: name,
phone: newTechPhone.value.trim() || '',
traccar_device_id: newTechDevice.value || '',
+ ...extraFields,
})
newTechName.value = ''
newTechPhone.value = ''
@@ -44,17 +65,141 @@ export function useTechManagement (store, invalidateRoutes) {
finally { addingTech.value = false }
}
- async function removeTech (tech) {
- if (!confirm(`Supprimer ${tech.fullName} ?`)) return
+ // ── Absence flow ──
+
+ function openAbsenceModal (tech) {
+ absenceModalTech.value = tech
+ const today = new Date().toISOString().slice(0, 10)
+ absenceForm.value = {
+ reason: tech.absenceReason || 'vacation',
+ from: tech.absenceFrom || today,
+ until: tech.absenceUntil || '',
+ jobAction: 'unassign', // 'unassign' | 'reassign'
+ }
+ absenceModalOpen.value = true
+ }
+
+ async function confirmAbsence () {
+ const tech = absenceModalTech.value
+ if (!tech) return
+ absenceProcessing.value = true
+
try {
+ const { reason, from, until, jobAction } = absenceForm.value
+
+ // 1. Handle assigned jobs
const linkedJobs = store.jobs.filter(j => j.assignedTech === tech.id)
- for (const job of linkedJobs) {
- await store.unassignJob(job.id)
+ if (linkedJobs.length > 0) {
+ if (jobAction === 'unassign') {
+ for (const job of linkedJobs) {
+ await store.unassignJob(job.id)
+ }
+ }
+ // 'reassign' is handled by the user manually after — jobs stay visible in unassigned pool
}
- await store.deleteTechnician(tech.id)
+
+ // 2. Update tech status + absence fields
+ tech.status = 'off'
+ tech.active = false
+ tech.absenceReason = reason
+ tech.absenceFrom = from || null
+ tech.absenceUntil = until || null
+ tech.absenceStartTime = null
+ tech.absenceEndTime = null
+
+ await updateTech(tech.name || tech.id, {
+ status: toErpStatus('off'),
+ absence_reason: reason,
+ absence_from: from || '',
+ absence_until: until || '',
+ absence_start_time: '',
+ absence_end_time: '',
+ })
+
+ absenceModalOpen.value = false
} catch (e) {
const msg = e?.message || String(e)
- alert('Erreur suppression:\n' + msg.replace(/<[^>]+>/g, ''))
+ alert('Erreur: ' + msg.replace(/<[^>]+>/g, ''))
+ }
+ absenceProcessing.value = false
+ }
+
+ async function endAbsence (tech) {
+ tech.status = 'available'
+ tech.active = true
+ tech.absenceReason = ''
+ tech.absenceFrom = null
+ tech.absenceUntil = null
+ tech.absenceStartTime = null
+ tech.absenceEndTime = null
+
+ try {
+ await updateTech(tech.name || tech.id, {
+ status: toErpStatus('available'),
+ absence_reason: '',
+ absence_from: '',
+ absence_until: '',
+ absence_start_time: '',
+ absence_end_time: '',
+ })
+ } catch (_e) {
+ tech.status = 'off'
+ tech.active = false
+ }
+ }
+
+ // Permanent deactivation (non-dispatchable resource like support staff)
+ async function deactivateTech (tech) {
+ if (!confirm(`Désactiver ${tech.fullName} du dispatch ?\n\nLa ressource ne sera plus dispatchable mais reste dans le système.`)) return
+ try {
+ const linkedJobs = store.jobs.filter(j => j.assignedTech === tech.id)
+ for (const job of linkedJobs) await store.unassignJob(job.id)
+ tech.status = 'inactive'
+ tech.active = false
+ tech.absenceReason = ''
+ tech.absenceFrom = null
+ tech.absenceUntil = null
+ tech.absenceStartTime = null
+ tech.absenceEndTime = null
+ await updateTech(tech.name || tech.id, { status: toErpStatus('inactive'), absence_reason: '', absence_from: '', absence_until: '', absence_start_time: '', absence_end_time: '' })
+ } catch (e) {
+ alert('Erreur: ' + (e?.message || String(e)).replace(/<[^>]+>/g, ''))
+ }
+ }
+
+ async function reactivateTech (tech) {
+ tech.status = 'available'
+ tech.active = true
+ tech.absenceReason = ''
+ tech.absenceFrom = null
+ tech.absenceUntil = null
+ tech.absenceStartTime = null
+ tech.absenceEndTime = null
+ try {
+ await updateTech(tech.name || tech.id, { status: toErpStatus('available'), absence_reason: '', absence_from: '', absence_until: '', absence_start_time: '', absence_end_time: '' })
+ } catch (_e) {
+ tech.status = 'inactive'
+ tech.active = false
+ }
+ }
+
+ async function removeTech (tech) {
+ if (!confirm(`⚠ SUPPRIMER DÉFINITIVEMENT ${tech.fullName} ?\n\nCette action est irréversible.`)) return
+ try {
+ const linkedJobs = store.jobs.filter(j => j.assignedTech === tech.id)
+ for (const job of linkedJobs) await store.unassignJob(job.id)
+ await store.deleteTechnician(tech.id)
+ } catch (e) {
+ alert('Erreur: ' + (e?.message || String(e)).replace(/<[^>]+>/g, ''))
+ }
+ }
+
+ async function saveWeeklySchedule (tech, schedule) {
+ tech.weeklySchedule = { ...schedule }
+ try {
+ await updateTech(tech.name || tech.id, { weekly_schedule: JSON.stringify(schedule) })
+ } catch (e) {
+ alert('Erreur: ' + (e?.message || String(e)).replace(/<[^>]+>/g, ''))
}
}
@@ -69,6 +214,10 @@ export function useTechManagement (store, invalidateRoutes) {
return {
editingTech, newTechName, newTechPhone, newTechDevice, addingTech,
- saveTechField, addTech, removeTech, saveTraccarLink,
+ absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing,
+ saveTechField, addTech,
+ openAbsenceModal, confirmAbsence, endAbsence,
+ deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule,
+ ABSENCE_REASONS,
}
}
diff --git a/apps/ops/src/composables/useUnifiedCreate.js b/apps/ops/src/composables/useUnifiedCreate.js
new file mode 100644
index 0000000..64e7fd9
--- /dev/null
+++ b/apps/ops/src/composables/useUnifiedCreate.js
@@ -0,0 +1,320 @@
+import { reactive, ref, computed } from 'vue'
+import { Notify } from 'quasar'
+import { createDoc, listDocs } from 'src/api/erp'
+import { createJob, fetchTags, createTag, updateTag, renameTag, deleteTag } from 'src/api/dispatch'
+
+const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
+
+/**
+ * Composable for unified ticket/task/work-order creation.
+ *
+ * @param {import('vue').Ref} mode - 'ticket' | 'task' | 'work-order'
+ * @param {Object} opts - Optional: { technicians, allTags, getTagColor }
+ */
+function nextWeekday () {
+ const d = new Date()
+ const day = d.getDay()
+ if (day === 6) d.setDate(d.getDate() + 2) // Sat → Mon
+ else if (day === 0) d.setDate(d.getDate() + 1) // Sun → Mon
+ return d.toISOString().slice(0, 10)
+}
+
+export function useUnifiedCreate (mode, opts = {}) {
+ // ── Form state ────────────────────────────────────────────────────────────
+ const form = reactive({
+ subject: '',
+ priority: 'medium',
+ description: '',
+ issue_type: null,
+ service_location: null,
+ job_type: 'Autre',
+ duration_h: 1,
+ address: '',
+ assigned_tech: null,
+ scheduled_date: '',
+ depends_on: null, // parent dependency (ticket/task/job name)
+ tags: [], // [{ tag, level?, required? }] or ['label', ...]
+ _latitude: null,
+ _longitude: null,
+ _customer: '',
+ _source_issue: '',
+ _subscription: '',
+ })
+
+ const submitting = ref(false)
+
+ // ── Field visibility by mode ──────────────────────────────────────────────
+ const fields = computed(() => {
+ const m = typeof mode === 'string' ? mode : mode.value
+ return {
+ showIssueType: m === 'ticket',
+ showServiceLocation: m === 'ticket',
+ showJobType: m === 'work-order',
+ showAddress: m === 'work-order',
+ showDuration: m === 'work-order' || m === 'task',
+ showTags: m === 'work-order' || m === 'task',
+ showTech: m === 'work-order',
+ showScheduledDate: m === 'work-order' || m === 'task',
+ showParent: true,
+ }
+ })
+
+ // ── Tags (standalone mode — used when not injected from dispatch) ─────────
+ const internalTags = ref([])
+ const tagsLoaded = ref(false)
+
+ async function loadTags () {
+ if (tagsLoaded.value) return
+ try {
+ internalTags.value = await fetchTags()
+ tagsLoaded.value = true
+ } catch { /* tags optional */ }
+ }
+
+ function allTags () {
+ return opts.allTags?.value || opts.allTags || internalTags.value
+ }
+
+ function getTagColor (label) {
+ if (opts.getTagColor) return opts.getTagColor(label)
+ const t = allTags().find(t => t.label === label || t.name === label)
+ return t?.color || '#6b7280'
+ }
+
+ async function onCreateTag ({ label, color }) {
+ try {
+ const doc = await createTag(label, 'Custom', color)
+ internalTags.value.push({ name: doc.name || label, label, color, category: 'Custom' })
+ } catch (e) {
+ Notify.create({ type: 'negative', message: 'Erreur création tag: ' + e.message })
+ }
+ }
+
+ async function onUpdateTag ({ name, color }) {
+ try {
+ await updateTag(name, { color })
+ const t = internalTags.value.find(t => t.name === name || t.label === name)
+ if (t) t.color = color
+ } catch { /* silent */ }
+ }
+
+ async function onRenameTag ({ oldName, newName }) {
+ try {
+ await renameTag(oldName, newName)
+ const t = internalTags.value.find(t => t.name === oldName || t.label === oldName)
+ if (t) { t.name = newName; t.label = newName }
+ } catch { /* silent */ }
+ }
+
+ async function onDeleteTag (label) {
+ try {
+ await deleteTag(label)
+ internalTags.value = internalTags.value.filter(t => t.label !== label && t.name !== label)
+ } catch { /* silent */ }
+ }
+
+ // ── Parent dependency search ──────────────────────────────────────────────
+ const parentOptions = ref([])
+ let parentDebounce = null
+ let recentLoaded = false
+
+ // Load recent tickets on first open (no query needed)
+ async function loadRecentParents () {
+ if (recentLoaded && parentOptions.value.length) return
+ try {
+ const issues = await listDocs('Issue', {
+ fields: ['name', 'subject', 'status'],
+ limit: 15,
+ orderBy: 'creation desc',
+ })
+ parentOptions.value = issues.map(i => ({
+ label: `${i.name}: ${i.subject || ''}`,
+ value: i.name,
+ type: 'Issue',
+ status: i.status,
+ }))
+ recentLoaded = true
+ } catch { /* silent */ }
+ }
+
+ function searchParent (query, done) {
+ clearTimeout(parentDebounce)
+ // No query → show recent tickets
+ if (!query || query.length < 2) {
+ loadRecentParents().then(() => done?.(parentOptions.value))
+ return
+ }
+ parentDebounce = setTimeout(async () => {
+ try {
+ const [issues, tasks] = await Promise.all([
+ listDocs('Issue', {
+ or_filters: { subject: ['like', `%${query}%`], name: ['like', `%${query}%`] },
+ fields: ['name', 'subject', 'status'],
+ limit: 10,
+ orderBy: 'creation desc',
+ }),
+ listDocs('Task', {
+ or_filters: { subject: ['like', `%${query}%`], name: ['like', `%${query}%`] },
+ fields: ['name', 'subject', 'status'],
+ limit: 10,
+ orderBy: 'creation desc',
+ }),
+ ])
+ const results = [
+ ...issues.map(i => ({ label: `${i.name}: ${i.subject || ''}`, value: i.name, type: 'Issue', status: i.status })),
+ ...tasks.map(t => ({ label: `${t.name}: ${t.subject || ''}`, value: t.name, type: 'Task', status: t.status })),
+ ]
+ parentOptions.value = results
+ done?.(results)
+ } catch { done?.([]) }
+ }, 300)
+ }
+
+ // ── Best tech ranking ─────────────────────────────────────────────────────
+ const findingBest = ref(false)
+ const ranking = ref([])
+
+ async function findBestTech () {
+ findingBest.value = true
+ try {
+ const res = await fetch(HUB_URL + '/dispatch/best-tech', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ latitude: form._latitude,
+ longitude: form._longitude,
+ duration_h: form.duration_h,
+ date: form.scheduled_date || new Date().toISOString().slice(0, 10),
+ }),
+ })
+ const data = await res.json()
+ ranking.value = data.ranking || []
+ } catch (e) {
+ Notify.create({ type: 'negative', message: 'Erreur calcul optimal' })
+ } finally {
+ findingBest.value = false
+ }
+ }
+
+ // ── Reset form from context ───────────────────────────────────────────────
+ function resetForm (ctx = {}) {
+ form.subject = ctx.subject || ''
+ form.priority = ctx.priority || 'medium'
+ form.description = ctx.description || ''
+ form.issue_type = ctx.issue_type || null
+ form.service_location = ctx.service_location || null
+ form.job_type = ctx.job_type || 'Autre'
+ form.duration_h = ctx.duration_h || 1
+ form.address = ctx.address || ''
+ form.assigned_tech = ctx.assigned_tech || null
+ form.scheduled_date = ctx.scheduled_date || ''
+ form.depends_on = ctx.depends_on || null
+ form.tags = ctx.tags || []
+ form._latitude = ctx.latitude || null
+ form._longitude = ctx.longitude || null
+ form._customer = ctx.customer || ''
+ form._source_issue = ctx.source_issue || ''
+ form._subscription = ctx.subscription || ''
+ ranking.value = []
+ }
+
+ // ── Submit — routes to correct backend ────────────────────────────────────
+ async function submit () {
+ if (!form.subject?.trim()) {
+ Notify.create({ type: 'warning', message: 'Le sujet est requis' })
+ return null
+ }
+ submitting.value = true
+ try {
+ const m = typeof mode === 'string' ? mode : mode.value
+ let result
+
+ if (m === 'ticket') {
+ const data = {
+ customer: form._customer,
+ subject: form.subject.trim(),
+ priority: capitalize(form.priority),
+ status: 'Open',
+ description: form.description || '',
+ }
+ if (form.issue_type) data.issue_type = form.issue_type
+ if (form.service_location) data.service_location = form.service_location
+ if (form.depends_on) data.depends_on = form.depends_on
+ result = await createDoc('Issue', data)
+ Notify.create({ type: 'positive', message: 'Ticket créé' })
+
+ } else if (m === 'task') {
+ const data = {
+ subject: form.subject.trim(),
+ priority: capitalize(form.priority),
+ status: 'Open',
+ description: form.description || '',
+ }
+ if (form.duration_h) data.expected_time = form.duration_h
+ if (form.scheduled_date) data.exp_start_date = form.scheduled_date
+ if (form.depends_on) data.parent_task = form.depends_on
+ result = await createDoc('Task', data)
+ Notify.create({ type: 'positive', message: 'Tâche créée' })
+
+ } else if (m === 'work-order') {
+ const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase()
+ const payload = {
+ ticket_id: ticketId,
+ subject: form.subject.trim(),
+ address: form.address,
+ duration_h: form.duration_h,
+ priority: form.priority,
+ status: form.assigned_tech ? 'assigned' : 'open',
+ job_type: form.job_type,
+ source_issue: form._source_issue,
+ customer: form._customer,
+ service_location: form.service_location || '',
+ depends_on: form.depends_on || '',
+ scheduled_date: form.scheduled_date || (form.assigned_tech ? nextWeekday() : ''),
+ notes: form.description || '',
+ assigned_tech: form.assigned_tech || '',
+ latitude: form._latitude || '',
+ longitude: form._longitude || '',
+ tags: form.tags.map(t => typeof t === 'string' ? t : t.tag).join(','),
+ }
+ result = await createJob(payload)
+ Notify.create({ type: 'positive', message: form.assigned_tech ? 'Travail créé et assigné' : 'Travail créé' })
+ }
+
+ return result
+ } catch (err) {
+ Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
+ return null
+ } finally {
+ submitting.value = false
+ }
+ }
+
+ return {
+ form,
+ fields,
+ submitting,
+ resetForm,
+ submit,
+ // Tags
+ loadTags,
+ allTagsList: computed(() => allTags()),
+ getTagColor,
+ onCreateTag,
+ onUpdateTag,
+ onRenameTag,
+ onDeleteTag,
+ // Parent
+ parentOptions,
+ searchParent,
+ // Best tech
+ findingBest,
+ ranking,
+ findBestTech,
+ }
+}
+
+function capitalize (s) {
+ if (!s) return s
+ return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
+}
diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js
index 5fcf9e9..d1efb58 100644
--- a/apps/ops/src/config/nav.js
+++ b/apps/ops/src/config/nav.js
@@ -1,14 +1,13 @@
// Ops sidebar navigation + search filter options
+// `requires` = capability needed to see this nav item (null = always visible)
export const navItems = [
- { path: '/', icon: 'LayoutDashboard', label: 'Tableau de bord' },
- { path: '/clients', icon: 'Users', label: 'Clients' },
- { path: '/dispatch', icon: 'Truck', label: 'Dispatch' },
- { path: '/tickets', icon: 'Ticket', label: 'Tickets' },
- { path: '/equipe', icon: 'UsersRound', label: 'Équipe' },
- { path: '/rapports', icon: 'BarChart3', label: 'Rapports' },
- { path: '/ocr', icon: 'ScanText', label: 'OCR Factures' },
- { path: '/telephony', icon: 'Phone', label: 'Téléphonie' },
- { path: '/settings', icon: 'Settings', label: 'Paramètres' },
+ { path: '/', icon: 'LayoutDashboard', label: 'Tableau de bord', requires: 'view_dashboard_kpi' },
+ { path: '/clients', icon: 'Users', label: 'Clients', requires: 'view_clients' },
+ { path: '/dispatch', icon: 'Truck', label: 'Dispatch', requires: 'view_all_jobs' },
+ { path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
+ { path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
+ { path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
+ { path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
]
export const territoryOptions = [
diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue
index 3b91b75..0be0fae 100644
--- a/apps/ops/src/layouts/MainLayout.vue
+++ b/apps/ops/src/layouts/MainLayout.vue
@@ -29,8 +29,8 @@
- {{ auth.user || 'User' }}
- {{ auth.user || 'Déconnexion' }}
+ {{ userName || auth.user || 'User' }}
+ {{ userName || auth.user || 'Déconnexion' }}
@@ -97,6 +97,17 @@
+
+
+
+
+
+
+
+ Conversations
+
+
+
@@ -104,16 +115,28 @@
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth'
+import { usePermissions } from 'src/composables/usePermissions'
import { listDocs } from 'src/api/erp'
-import { navItems } from 'src/config/nav'
+import { navItems as allNavItems } from 'src/config/nav'
import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
- ScanText, Phone, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
+ Settings, LogOut, PanelLeftOpen, PanelLeftClose,
} from 'lucide-vue-next'
+import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
+import { useConversations } from 'src/composables/useConversations'
-const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, ScanText, Phone, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
+const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
+
+const { panelOpen, activeCount: convCount } = useConversations()
+function toggleConvPanel () { panelOpen.value = !panelOpen.value }
const auth = useAuthStore()
+const { can, isLoaded, userName } = usePermissions()
+
+// Filter nav items based on user capabilities
+const navItems = computed(() =>
+ allNavItems.filter(n => !n.requires || can(n.requires))
+)
const route = useRoute()
const router = useRouter()
const drawer = ref(true)
@@ -122,7 +145,7 @@ const collapsed = ref(localStorage.getItem('ops-sidebar-collapsed') !== 'false')
const sidebarW = computed(() => collapsed.value ? 64 : 220)
const isDispatch = computed(() => route.path === '/dispatch')
const currentNav = computed(() =>
- navItems.find(n => n.path === route.path) || navItems.find(n => route.path.startsWith(n.path) && n.path !== '/')
+ navItems.value.find(n => n.path === route.path) || navItems.value.find(n => route.path.startsWith(n.path) && n.path !== '/')
)
function isActive (path) {
diff --git a/apps/ops/src/modules/dispatch/components/MonthCalendar.vue b/apps/ops/src/modules/dispatch/components/MonthCalendar.vue
index 19b50d9..7a20f7f 100644
--- a/apps/ops/src/modules/dispatch/components/MonthCalendar.vue
+++ b/apps/ops/src/modules/dispatch/components/MonthCalendar.vue
@@ -1,6 +1,6 @@
@@ -55,6 +81,14 @@ function dayJobCount (dateStr) {
:class="{ 'sb-month-day-out': day.getMonth() !== anchorDate.getMonth(), 'sb-month-day-today': isDayToday(day) }"
@click="emit('go-to-day', day)">
{{ day.getDate() }}
+
+
+ 👷 {{ daySummary(localDateStr(day)).present }}
+
+
+ ⏸ {{ daySummary(localDateStr(day)).absent }}
+
+
n[0]).join('').toUpperCase().slice(0,2) }}
+
+ {{ fmtDur(daySummary(localDateStr(day)).loadH) }}/{{ fmtDur(daySummary(localDateStr(day)).capH) }}
+
{{ dayJobCount(localDateStr(day)) }} job{{ dayJobCount(localDateStr(day))>1?'s':'' }}
diff --git a/apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue b/apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue
new file mode 100644
index 0000000..c61ab48
--- /dev/null
+++ b/apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue
@@ -0,0 +1,237 @@
+
+
+
+
+
+
Publier & envoyer l'horaire par SMS
+
+
+
+
+
+
+
+
+
+
+
Employés
+
+
+
+ T
+ {{ opt.label || opt }}
+
+
+
+
+
+
+
+ {{ draftCount }} travaux brouillon à publier pour cette période
+
+
+ Aucun brouillon à publier pour cette sélection
+
+
+
+
+
+
+
+
Message additionnel inclus dans le SMS envoyé aux employés
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/modules/dispatch/components/TimelineRow.vue b/apps/ops/src/modules/dispatch/components/TimelineRow.vue
index e029c1a..404cda1 100644
--- a/apps/ops/src/modules/dispatch/components/TimelineRow.vue
+++ b/apps/ops/src/modules/dispatch/components/TimelineRow.vue
@@ -22,6 +22,8 @@ const emit = defineEmits([
'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx',
'assist-ctx', 'hover-job', 'unhover-job',
'block-move', 'block-resize',
+ 'open-absence', 'end-absence',
+ 'absence-resize',
])
const TECH_COLORS = inject('TECH_COLORS')
@@ -29,32 +31,44 @@ const jobColor = inject('jobColor')
const selectedJob = inject('selectedJob')
const hoveredJobId = inject('hoveredJobId')
const periodLoadH = inject('periodLoadH')
+const techPeriodCapacityH = inject('techPeriodCapacityH')
+const techDayEndH = inject('techDayEndH')
const getTagColor = inject('getTagColor')
const isJobMultiSelected = inject('isJobMultiSelected')
-
{}" @drop.prevent="emit('reorder-drop', $event, tech)">
-
+
+ {{ ({Véhicule:'🚛',Outil:'🔧',Salle:'🏢',Équipement:'📦',Nacelle:'🏗️',Grue:'🏗️',Fusionneuse:'🔧',OTDR:'📡'})[tech.resourceCategory||tech.fullName] || '🔧' }}
+
+
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
{{ tech.fullName }}
- #
{{ stOf(tech).label }}
- {{ fmtDur(periodLoadH(tech)) }}
+ {{ tech.absenceReason }}
+ {{ fmtDur(periodLoadH(tech)) }}/{{ fmtDur(techPeriodCapacityH(tech)) }}
+
+
+ #
+ 🗓
+ ⏸
+ ▶
@@ -71,12 +85,23 @@ const isJobMultiSelected = inject('isJobMultiSelected')
-
+
+
+
+
+ {{ seg.reasonIcon }}
+ {{ seg.reasonLabel }}
+ {{ seg.from }} → {{ seg.until }}
+
+
+
+
-
{{ seg.fromRoute?'':'~' }}{{ seg.travelMin }}min
@@ -100,7 +125,7 @@ const isJobMultiSelected = inject('isJobMultiSelected')
= from && ds <= until
+}
+
+function isScheduleOff (tech, d) {
+ const ds = localDateStr(d)
+ return !techDaySchedule(tech, ds)
+}
+
+function isAbsentOnDay (tech, d) {
+ return isExplicitAbsent(tech, d) || isScheduleOff(tech, d)
+}
+
+// Returns absence info for a given tech+day: { icon, label, isFullDay, hours, timeRange, remainH }
+function absenceInfo (tech, d) {
+ const ds = localDateStr(d)
+ // Schedule off-day (always full day)
+ if (isScheduleOff(tech, d) && !isExplicitAbsent(tech, d)) {
+ return { icon: '📅', label: 'Repos', isFullDay: true, hours: 0, timeRange: null, remainH: 0 }
+ }
+ // Explicit absence
+ const r = ABSENCE_REASONS.find(x => x.value === tech.absenceReason)
+ const icon = r ? r.icon : '⏸'
+ const label = r ? r.label : 'Absent'
+ const sched = techDaySchedule(tech, ds)
+ const schedStartH = sched ? sched.startH : 8
+ const schedEndH = sched ? sched.endH : 16
+ const schedHours = sched ? sched.hours : 8
+
+ const absStartH = tech.absenceStartTime ? timeToH(tech.absenceStartTime) : schedStartH
+ const absEndH = tech.absenceEndTime ? timeToH(tech.absenceEndTime) : schedEndH
+ const absHours = Math.max(0, absEndH - absStartH)
+ const isFullDay = absStartH <= schedStartH && absEndH >= schedEndH
+ const remainH = Math.max(0, schedHours - absHours)
+
+ const fmt = h => { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return hh + 'h' + (mm ? (mm < 10 ? '0' : '') + mm : '') }
+ const timeRange = !isFullDay ? fmt(absStartH) + '–' + fmt(absEndH) : null
+
+ return { icon, label, isFullDay, hours: absHours, timeRange, remainH }
+}
+
defineExpose({ isDayToday })
@@ -56,7 +103,7 @@ defineExpose({ isDayToday })
+ class="sb-row sb-row-cal" :class="{ 'sb-row-sel': selectedTechId===tech.id, 'sb-row-absent': tech.status==='off' }">
{}" @drop.prevent="emit('tech-reorder-drop', $event, tech)">
@@ -69,17 +116,32 @@ defineExpose({ isDayToday })
{{ stOf(tech).label }}
+ {{ tech.absenceReason }}
+
+
+ 🗓
+ ⏸
+ ▶
{}" @dragleave="()=>{}"
@drop.prevent="emit('cal-drop', $event, tech, localDateStr(d))">
+
+
{{ absenceInfo(tech, d).icon }} {{ absenceInfo(tech, d).label }}
+
Journée complète
+
+ {{ absenceInfo(tech, d).timeRange }} · reste {{ fmtDur(absenceInfo(tech, d).remainH) }}
+
+
-
{{ fmtDur(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)) }}/8h
+
{{ fmtDur(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)) }}/{{ fmtDur(techDayCapacityH(tech,localDateStr(d))||8) }}
diff --git a/apps/ops/src/modules/dispatch/components/WoCreateModal.vue b/apps/ops/src/modules/dispatch/components/WoCreateModal.vue
index c9ac0cb..a6b9793 100644
--- a/apps/ops/src/modules/dispatch/components/WoCreateModal.vue
+++ b/apps/ops/src/modules/dispatch/components/WoCreateModal.vue
@@ -1,6 +1,9 @@
@@ -73,11 +116,47 @@ function close () { emit('update:modelValue', null); emit('cancel') }
+
+
+
+
+ ⚡ Classement optimal
+ ✕
+
+
+
+ 🥇
+ 🥈
+ 🥉
+ #{{ idx + 1 }}
+
+
+ {{ r.tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase() }}
+
+
+
{{ r.tech.fullName }}
+
+ {{ reason }}
+
+
+
+ Choisir
+
+
+
+
Date planifiée
@@ -95,3 +174,55 @@ function close () { emit('update:modelValue', null); emit('cancel') }
+
+
diff --git a/apps/ops/src/pages/AgentFlowsPage.vue b/apps/ops/src/pages/AgentFlowsPage.vue
new file mode 100644
index 0000000..d811ba4
--- /dev/null
+++ b/apps/ops/src/pages/AgentFlowsPage.vue
@@ -0,0 +1,468 @@
+
+
+
+
+ Chargement des flows...
+
+
+
+
+
+
+
+
+ {{ flows.persona?.name || 'Agent' }} — {{ flows.persona?.role || '' }}
+
+
+
+ {{ r }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ intent.label }}
+
+ +
+
+
+
+
+
+
+ Déclencheur:
+
+
+
+
+
+
+
+
+
+ + Ajouter une étape
+
+
+
+
+
+
+
+
+
+ Modifier: {{ editingStep.label || editingStep.id }}
+ ✕
+
+
+
+ Enregistrer
+ Annuler
+
+
+
+
+
+
+
+
+ System Prompt généré
+ ✕
+
+
{{ generatedPrompt }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue
index 57b0a68..d70e55f 100644
--- a/apps/ops/src/pages/ClientDetailPage.vue
+++ b/apps/ops/src/pages/ClientDetailPage.vue
@@ -1,23 +1,20 @@
-
-
-
-
-
+
+
-
+
@@ -26,13 +23,10 @@
-
- Aucun lieu de service
-
+ Aucun lieu de service
-
@@ -68,14 +54,17 @@
{{ locHasSubs(loc.name) ? loc.status : 'Inactif' }}
+
+ Supprimer cette adresse
+
-
-
@@ -87,13 +76,13 @@
· loc.network_id = v.value" />
-
+
-
+
{{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }}
SN: {{ eq.serial_number }}
@@ -101,16 +90,14 @@
OLT: {{ eq.olt_name }} — Slot {{ eq.olt_slot }}/Port {{ eq.olt_port }}/ONT {{ eq.olt_ontid }}
IP: {{ eq.ip_address }}
{{ eq.status }}
-
-
+
-
- {{ isOnline(eq.serial_number) ? '● En ligne' : '● Hors ligne' }}
-
- — {{ formatTimeAgo(getDevice(eq.serial_number).lastInform) }}
-
+
+ {{ combinedStatus(eq.serial_number).online ? '● En ligne' : '● Hors ligne' }}
+ — {{ formatTimeAgo(getDevice(eq.serial_number).lastInform) }}
-
+ {{ combinedStatus(eq.serial_number).detail }}
+
Fibre: {{ getDevice(eq.serial_number).opticalStatus }}
@@ -121,7 +108,7 @@
- WiFi: {{ getDevice(eq.serial_number).wifi.totalClients != null ? getDevice(eq.serial_number).wifi.totalClients : ((getDevice(eq.serial_number).wifi.radio1?.clients || 0) + (getDevice(eq.serial_number).wifi.radio2?.clients || 0) + (getDevice(eq.serial_number).wifi.radio3?.clients || 0)) }} clients
+ WiFi: {{ getDevice(eq.serial_number).wifi.totalClients ?? ((getDevice(eq.serial_number).wifi.radio1?.clients || 0) + (getDevice(eq.serial_number).wifi.radio2?.clients || 0) + (getDevice(eq.serial_number).wifi.radio3?.clients || 0)) }} clients
({{ getDevice(eq.serial_number).wifi.meshClients }} via mesh)
@@ -130,7 +117,6 @@
SSID: {{ getDevice(eq.serial_number).ssid }}
-
@@ -144,15 +130,19 @@
+
+
+
+ Ajouter un equipement
+
-
+
Abonnements ({{ locSubs(loc.name).length }})
Aucun
-
-
-
+
+
+
+
+
+ Ajouter un equipement
+
+
+ {{ addEquipLoc.address_line || addEquipLoc.location_name }}
+
+
+
+
+
+
+
+
+
+
+
Scanner un code-barres / serial
+
Photo de l'etiquette, code-barres ou QR code
+
+
+
+
+
+
+
+
Codes detectes :
+
+
+ {{ bc.value }}
+ Cliquer pour utiliser comme N serie
+
+
+
+
+ {{ scannerState.error.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cet equipement existe deja : {{ equipLookupResult.equipment.name }}
+ ({{ equipLookupResult.equipment.equipment_type }}, {{ equipLookupResult.equipment.service_location || 'non assigne' }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
openModal(dt, name, t)"
- @open-pdf="openPdf"
- @save-field="saveSubField"
- @toggle-recurring="toggleRecurringModal"
- @dispatch-created="onDispatchCreated"
+ @open-pdf="openPdf" @save-field="saveSubField"
+ @toggle-recurring="toggleRecurringModal" @dispatch-created="onDispatchCreated"
@dispatch-deleted="name => { modalDispatchJobs = modalDispatchJobs.filter(j => j.name !== name) }"
- />
+ @deleted="onEntityDeleted" />
diff --git a/apps/ops/src/pages/ClientsPage.vue b/apps/ops/src/pages/ClientsPage.vue
index beef76d..31ed5dc 100644
--- a/apps/ops/src/pages/ClientsPage.vue
+++ b/apps/ops/src/pages/ClientsPage.vue
@@ -150,9 +150,6 @@ function onRequest (props) {
doSearch()
}
-// Don't auto-load all clients — start empty, load only on search or if ?q= is present
-onMounted(() => {
- if (search.value.trim()) doSearch()
-})
+onMounted(() => doSearch())
watch(() => route.query.q, (q) => { if (q) { search.value = q; doSearch() } })
diff --git a/apps/ops/src/pages/DispatchPage.vue b/apps/ops/src/pages/DispatchPage.vue
index 82d51ce..4fcf066 100644
--- a/apps/ops/src/pages/DispatchPage.vue
+++ b/apps/ops/src/pages/DispatchPage.vue
@@ -4,21 +4,20 @@ import { useDispatchStore } from 'src/stores/dispatch'
import { useAuthStore } from 'src/stores/auth'
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
import { fetchOpenRequests } from 'src/api/service-request'
-import { updateJob } from 'src/api/dispatch'
+import { updateJob, updateTech } from 'src/api/dispatch'
-// ── Components ──────────────────────────────────────────────────────────────────
import TagEditor from 'src/components/shared/TagEditor.vue'
import TimelineRow from 'src/modules/dispatch/components/TimelineRow.vue'
import BottomPanel from 'src/modules/dispatch/components/BottomPanel.vue'
import JobEditModal from 'src/modules/dispatch/components/JobEditModal.vue'
-import WoCreateModal from 'src/modules/dispatch/components/WoCreateModal.vue'
+import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
+import PublishScheduleModal from 'src/modules/dispatch/components/PublishScheduleModal.vue'
import WeekCalendar from 'src/modules/dispatch/components/WeekCalendar.vue'
import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue'
import RightPanel from 'src/modules/dispatch/components/RightPanel.vue'
-// ── Composables ─────────────────────────────────────────────────────────────────
import {
- localDateStr, timeToH, fmtDur,
+ localDateStr, timeToH, hToTime, fmtDur,
SVC_COLORS, prioLabel, prioClass, serializeAssistants,
jobColor as _jobColorBase, ICON, prioColor,
} from 'src/composables/useHelpers'
@@ -34,66 +33,39 @@ import { useResourceFilter } from 'src/composables/useResourceFilter'
import { useTagManagement } from 'src/composables/useTagManagement'
import { useContextMenus } from 'src/composables/useContextMenus'
import { useTechManagement } from 'src/composables/useTechManagement'
+import { useAddressSearch } from 'src/composables/useAddressSearch'
-// ─── Store ────────────────────────────────────────────────────────────────────
const store = useDispatchStore()
const auth = useAuthStore()
const erpUrl = BASE_URL || window.location.origin
-// ─── Period Navigation ───────────────────────────────────────────────────────
const {
currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr,
prevPeriod, nextPeriod, goToToday, goToDay,
} = usePeriodNavigation()
-// ─── Resource Filter ─────────────────────────────────────────────────────────
const {
- selectedResIds, filterStatus, filterTags, searchQuery, techSort, manualOrder,
- filteredResources, resSelectorOpen, tempSelectedIds, dragReorderTech,
+ selectedResIds, filterStatus, filterTags, filterResourceType, searchQuery, techSort, manualOrder,
+ filteredResources, groupedResources, availableGroups, filterGroup,
+ showInactive, inactiveCount, humanCount, materialCount, availableCategories,
+ resSelectorOpen, tempSelectedIds, dragReorderTech,
openResSelector, applyResSelector, toggleTempRes, clearFilters,
onTechReorderStart, onTechReorderDrop,
} = useResourceFilter(store)
-// ─── Tags ────────────────────────────────────────────────────────────────────
const techTagModal = ref(null)
const {
getTagColor, onCreateTag, onUpdateTag, onRenameTag, onDeleteTag,
_serializeTags, persistJobTags, persistTechTags,
} = useTagManagement(store)
-// ─── Address autocomplete ────────────────────────────────────────────────────
-const addrResults = ref([])
-const addrLoading = ref(false)
-let addrDebounce = null
-function searchAddr (q) {
- clearTimeout(addrDebounce)
- if (!q || q.length < 3) { addrResults.value = []; return }
- addrLoading.value = true
- addrDebounce = setTimeout(async () => {
- try {
- const res = await fetch(`/api/method/search_address?q=${encodeURIComponent(q)}`, { credentials: 'include' })
- const data = await res.json()
- addrResults.value = data.results || data.message?.results || []
- } catch { addrResults.value = [] }
- addrLoading.value = false
- }, 300)
-}
-function selectAddr (addr, target) {
- if (!target) { addrResults.value = []; return }
- target.address = addr.address_full
- if (addr.latitude) target.latitude = parseFloat(addr.latitude)
- if (addr.longitude) target.longitude = parseFloat(addr.longitude)
- if (addr.ville) target.ville = addr.ville
- if (addr.code_postal) target.code_postal = addr.code_postal
- addrResults.value = []
-}
+const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
function setEndDate (job, endDate) {
job.endDate = endDate || null
updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {})
}
-// ─── Layout state ─────────────────────────────────────────────────────────────
const filterPanelOpen = ref(false)
const projectsPanelOpen = ref(false)
const mapVisible = ref(localStorage.getItem('sbv2-map') === 'true')
@@ -101,7 +73,6 @@ const rightPanel = ref(null)
watch(mapVisible, v => localStorage.setItem('sbv2-map', v ? 'true' : 'false'))
-// ─── Edit modal (double-click) ───────────────────────────────────────────────
const editModal = ref(null)
function openEditModal (job) {
editModal.value = {
@@ -126,7 +97,6 @@ function confirmEdit () {
invalidateRoutes()
}
-// ─── Job schedule helpers ─────────────────────────────────────────────────────
function getJobDate (jobId) { return store.jobs.find(j => j.id === jobId)?.scheduledDate || null }
function getJobTime (jobId) { return store.jobs.find(j => j.id === jobId)?.startTime || null }
function setJobTime (jobId, time) {
@@ -138,13 +108,11 @@ function setJobTime (jobId, time) {
const timeModal = ref(null)
function openTimeModal (job, techId) {
- const cur = getJobTime(job.id)
- timeModal.value = { job, techId, time: cur || '08:00', hasPin: !!cur }
+ timeModal.value = { job, techId, time: getJobTime(job.id) || '08:00', hasPin: !!getJobTime(job.id) }
}
function confirmTime () { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, timeModal.value.time); timeModal.value = null }
function clearTime () { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, null); timeModal.value = null }
-// ─── Unscheduled / Pending ────────────────────────────────────────────────────
const pendingReqs = ref([])
const pendingLoading = ref(false)
async function loadPendingReqs () {
@@ -155,25 +123,18 @@ async function loadPendingReqs () {
const unscheduledJobs = computed(() => store.jobs.filter(j => !j.assignedTech))
const teamJobs = computed(() => store.jobs.filter(j => j.assistants?.length > 0))
-// ─── Job color ────────────────────────────────────────────────────────────────
function jobColor (job) { return _jobColorBase(job, TECH_COLORS, store) }
-// ─── Timeline geometry ────────────────────────────────────────────────────────
const PX_PER_HR = ref(80)
const ROW_H = 68
-const pxPerHr = computed(() => {
- if (currentView.value === 'week') return PX_PER_HR.value * 0.55
- if (currentView.value === 'month') return 0
- return PX_PER_HR.value
-})
+const pxPerHr = computed(() => currentView.value === 'week' ? PX_PER_HR.value * 0.55 : currentView.value === 'month' ? 0 : PX_PER_HR.value)
const dayW = computed(() => currentView.value === 'month' ? 110 : (H_END - H_START) * pxPerHr.value)
const totalW = computed(() => dayW.value * periodDays.value)
-// ─── Scheduler composable ────────────────────────────────────────────────────
const {
H_START, H_END, routeLegs, routeGeometry,
techAllJobsForDate, techDayJobsWithTravel,
- periodLoadH,
+ periodLoadH, techPeriodCapacityH, techDayEndH,
} = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor)
const hourTicks = computed(() => {
@@ -191,10 +152,8 @@ const hourTicks = computed(() => {
const isCalView = computed(() => currentView.value === 'week')
const unassignDropActive = ref(false)
-// ─── Undo composable ─────────────────────────────────────────────────────────
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
-// ─── Smart assign & full unassign (delegated to store) ───────────────────────
function smartAssign (job, newTechId, dateStr) { store.smartAssign(job.id, newTechId, dateStr) }
function fullUnassign (job) {
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] })
@@ -203,7 +162,6 @@ function fullUnassign (job) {
invalidateRoutes()
}
-// ─── Context menus ────────────────────────────────────────────────────────────
const {
ctxMenu, techCtx, assistCtx, assistNoteModal,
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
@@ -211,7 +169,6 @@ const {
ctxDetails, ctxMove, ctxUnschedule,
} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal })
-// ─── Bottom panel composable ──────────────────────────────────────────────────
const {
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
@@ -219,7 +176,6 @@ const {
btColWidths, btColW, startColResize,
} = useBottomPanel(store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart })
-// ─── Selection composable ────────────────────────────────────────────────────
const {
hoveredJobId, selectedJob, multiSelect,
selectJob: _selectJob, isJobMultiSelected, batchUnassign, batchMoveTo,
@@ -231,7 +187,6 @@ function selectJob (job, techId, isAssist = false, assistTechId = null, event =
_selectJob(job, techId, isAssist, assistTechId, event, rightPanel)
}
-// ─── Drag & Drop composable ──────────────────────────────────────────────────
const {
dragJob, dragSrc, dragIsAssist, dragBatchIds, dropGhost,
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
@@ -244,7 +199,60 @@ const {
pushUndo, smartAssign, invalidateRoutes,
})
-// ─── Map composable ──────────────────────────────────────────────────────────
+function startAbsenceResize (e, seg, tech, side) {
+ e.preventDefault()
+ const startX = e.clientX
+ const block = e.target.closest('.sb-block-absence')
+ const startW = block.offsetWidth
+ const startL = parseFloat(block.style.left)
+ const SNAP_PX = pxPerHr.value / 4 // 15-minute snap
+
+ function snapPx (px) { return Math.round(px / SNAP_PX) * SNAP_PX }
+
+ function onMove (ev) {
+ const dx = ev.clientX - startX
+ if (side === 'right') {
+ block.style.width = Math.max(SNAP_PX, snapPx(startW + dx)) + 'px'
+ } else {
+ const newL = snapPx(startL + dx)
+ const newW = startW + (startL - newL)
+ if (newW >= SNAP_PX && newL >= 0) {
+ block.style.left = newL + 'px'
+ block.style.width = newW + 'px'
+ }
+ }
+ // Update label
+ const curL = parseFloat(block.style.left)
+ const curW = parseFloat(block.style.width)
+ const sH = H_START + curL / pxPerHr.value
+ const eH = sH + curW / pxPerHr.value
+ const lbl = block.querySelector('.sb-absence-label')
+ if (lbl) lbl.textContent = `${hToTime(sH)} → ${hToTime(eH)}`
+ }
+
+ function onUp () {
+ document.removeEventListener('mousemove', onMove)
+ document.removeEventListener('mouseup', onUp)
+ const curL = parseFloat(block.style.left)
+ const curW = parseFloat(block.style.width)
+ const newStartH = H_START + curL / pxPerHr.value
+ const newEndH = newStartH + curW / pxPerHr.value
+ const startTime = hToTime(newStartH)
+ const endTime = hToTime(newEndH)
+ // Update local state
+ tech.absenceStartTime = startTime
+ tech.absenceEndTime = endTime
+ // Persist to ERPNext
+ updateTech(tech.name || tech.id, {
+ absence_start_time: startTime,
+ absence_end_time: endTime,
+ }).catch(() => {})
+ }
+
+ document.addEventListener('mousemove', onMove)
+ document.addEventListener('mouseup', onUp)
+}
+
let computeDayRoute = () => {}, drawMapMarkers = () => {}, drawSelectedRoute = () => {}, getMap = () => null
const _map = useMap({
store, MAPBOX_TOKEN, TECH_COLORS,
@@ -260,7 +268,6 @@ drawMapMarkers = _map.drawMapMarkers
drawSelectedRoute = _map.drawSelectedRoute
getMap = _map.getMap
-// ─── Route invalidation ──────────────────────────────────────────────────────
function invalidateRoutes () {
routeLegs.value = {}; routeGeometry.value = {}
if (currentView.value === 'day') {
@@ -281,7 +288,6 @@ watch(
},
)
-// ─── Move modal ───────────────────────────────────────────────────────────────
const moveModalOpen = ref(false)
const moveForm = ref(null)
function openMoveModal (job, srcTechId) {
@@ -297,30 +303,184 @@ async function confirmMove () {
moveModalOpen.value = false; bookingOverlay.value = null; invalidateRoutes()
}
-// ─── Booking overlay ──────────────────────────────────────────────────────────
const bookingOverlay = ref(null)
-
-// ─── WO creation modal ────────────────────────────────────────────────────────
-const woModal = ref(null)
+const woModalOpen = ref(false)
+const woModalCtx = ref({})
+const publishModalOpen = ref(false)
+const draftCount = computed(() => store.jobs.filter(j => !j.published && j.status !== 'completed' && j.status !== 'cancelled').length)
+const periodEndStr = computed(() => {
+ const ps = periodStart.value
+ if (!ps) return ''
+ const d = new Date(ps + 'T12:00:00')
+ d.setDate(d.getDate() + (periodDays.value || 7) - 1)
+ return d.toISOString().slice(0, 10)
+})
+function onPublished (jobNames) {
+ store.publishJobsLocal(jobNames)
+}
const gpsSettingsOpen = ref(false)
+const gpsShowInactive = ref(false)
+const gpsFilteredTechs = computed(() =>
+ gpsShowInactive.value ? store.technicians : store.technicians.filter(t => t.active !== false)
+)
-// ─── Tech management (GPS modal) ─────────────────────────────────────────────
const {
editingTech, newTechName, newTechPhone, newTechDevice, addingTech,
- saveTechField, addTech, removeTech, saveTraccarLink,
+ absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing,
+ saveTechField, addTech,
+ openAbsenceModal, confirmAbsence, endAbsence,
+ deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule,
+ ABSENCE_REASONS,
} = useTechManagement(store, invalidateRoutes)
-function openWoModal (prefillDate = null, prefillTech = null) {
- woModal.value = { subject: '', address: '', latitude: null, longitude: null, duration_h: 1, priority: 'low', note: '', tags: [], techId: prefillTech || '', date: prefillDate || todayStr }
+const newTechGroup = ref('')
+
+// ── Schedule editor modal ──
+import { WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS } from 'src/composables/useHelpers'
+const scheduleModalTech = ref(null)
+const scheduleForm = ref({})
+function openScheduleModal (tech) {
+ scheduleModalTech.value = tech
+ scheduleForm.value = {}
+ WEEK_DAYS.forEach(d => {
+ const day = tech.weeklySchedule?.[d]
+ scheduleForm.value[d] = day ? { on: true, start: day.start || '08:00', end: day.end || '16:00' } : { on: false, start: '08:00', end: '16:00' }
+ })
}
-async function confirmWo () {
- if (!woModal.value || !woModal.value.subject.trim()) return
- const { subject, address, duration_h, priority, techId, date, latitude, longitude, note, tags } = woModal.value
- await store.createJob({ subject, address, duration_h, priority, assigned_tech: techId || null, scheduled_date: date || null, latitude: latitude || null, longitude: longitude || null, note: note || '', tags: tags.map(t => ({ tag: t })) })
- woModal.value = null
+function applySchedulePreset (preset) {
+ WEEK_DAYS.forEach(d => {
+ const day = preset.schedule[d]
+ scheduleForm.value[d] = day ? { on: true, start: day.start, end: day.end } : { on: false, start: '08:00', end: '16:00' }
+ })
+}
+function confirmSchedule () {
+ const sched = {}
+ WEEK_DAYS.forEach(d => {
+ const f = scheduleForm.value[d]
+ sched[d] = f.on ? { start: f.start, end: f.end } : null
+ })
+ saveWeeklySchedule(scheduleModalTech.value, sched)
+ scheduleModalTech.value = null
+}
+
+// Group technicians for the resource selector modal
+const resSelectorGroupFilter = ref('')
+const resSelectorSearch = ref('')
+const savedPresets = ref(JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]'))
+const presetNameInput = ref('')
+const showPresetSave = ref(false)
+
+function savePreset () {
+ const name = presetNameInput.value.trim()
+ if (!name || !tempSelectedIds.value.length) return
+ const existing = savedPresets.value.findIndex(p => p.name === name)
+ const preset = { name, ids: [...tempSelectedIds.value], created: new Date().toISOString() }
+ if (existing >= 0) savedPresets.value.splice(existing, 1, preset)
+ else savedPresets.value.push(preset)
+ localStorage.setItem('sbv2-resPresets', JSON.stringify(savedPresets.value))
+ presetNameInput.value = ''
+ showPresetSave.value = false
+}
+
+function loadPreset (preset) {
+ tempSelectedIds.value = [...preset.ids]
+}
+
+function deletePreset (idx) {
+ savedPresets.value.splice(idx, 1)
+ localStorage.setItem('sbv2-resPresets', JSON.stringify(savedPresets.value))
+}
+
+const activePresetName = computed(() => {
+ if (!selectedResIds.value.length) return null
+ const ids = selectedResIds.value
+ return savedPresets.value.find(p => p.ids.length === ids.length && p.ids.every(id => ids.includes(id)))?.name || null
+})
+
+const resSelectorGroupsFiltered = computed(() => {
+ const grouper = (techs) => {
+ const groups = new Map()
+ for (const t of techs) {
+ const g = t.group || ''
+ if (!groups.has(g)) groups.set(g, [])
+ groups.get(g).push(t)
+ }
+ const sorted = [...groups.entries()].sort((a, b) => {
+ if (!a[0] && b[0]) return 1
+ if (a[0] && !b[0]) return -1
+ return a[0].localeCompare(b[0])
+ })
+ return sorted.map(([name, techs]) => ({ name, label: name || 'Sans groupe', techs }))
+ }
+ let techs = store.technicians.filter(t => t.status !== 'inactive')
+ // Apply group filter within selector
+ if (resSelectorGroupFilter.value) techs = techs.filter(t => t.group === resSelectorGroupFilter.value)
+ // Apply search filter
+ if (resSelectorSearch.value) {
+ const q = resSelectorSearch.value.toLowerCase()
+ techs = techs.filter(t => t.fullName.toLowerCase().includes(q))
+ }
+ return {
+ available: grouper(techs.filter(x => !tempSelectedIds.value.includes(x.id))),
+ selected: grouper(store.technicians.filter(x => tempSelectedIds.value.includes(x.id))),
+ }
+})
+
+const RES_ICONS = { 'Véhicule': '🚛', 'Outil': '🔧', 'Salle': '🏢', 'Équipement': '📦', 'Nacelle': '🏗️', 'Grue': '🏗️', 'Fusionneuse': '🔧', 'OTDR': '📡' }
+function resIcon (t) {
+ if (t.resourceType !== 'material') return ''
+ return RES_ICONS[t.resourceCategory] || RES_ICONS[t.fullName] || '🔧'
+}
+
+function openResSelectorFull () {
+ resSelectorGroupFilter.value = filterGroup.value
+ resSelectorSearch.value = ''
+ openResSelector()
+}
+
+function applyGroupFilter () {
+ filterGroup.value = resSelectorGroupFilter.value
+ resSelectorOpen.value = false
+}
+
+async function onTechStatusChange (tech, value) {
+ tech.status = value
+ tech.active = value !== 'inactive'
+ saveTechField(tech, 'status', value)
+}
+
+async function saveTechGroup (tech, value) {
+ const trimmed = (value || '').trim()
+ if (trimmed === tech.group) return
+ tech.group = trimmed
+ try { await import('src/api/dispatch').then(m => m.updateTech(tech.name || tech.id, { tech_group: trimmed })) }
+ catch {}
+}
+
+function openWoModal (prefillDate = null, prefillTech = null) {
+ woModalCtx.value = {
+ scheduled_date: prefillDate || todayStr,
+ assigned_tech: prefillTech || null,
+ }
+ woModalOpen.value = true
+}
+async function confirmWo (formData) {
+ const job = await store.createJob({
+ subject: formData.subject,
+ address: formData.address,
+ duration_h: formData.duration_h,
+ priority: formData.priority,
+ assigned_tech: formData.assigned_tech || null,
+ scheduled_date: formData.scheduled_date || null,
+ latitude: formData._latitude || null,
+ longitude: formData._longitude || null,
+ note: formData.description || '',
+ tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t),
+ depends_on: formData.depends_on || '',
+ })
+ return job
}
-// ─── Auto-dispatch composable ────────────────────────────────────────────────
const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs,
bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes,
@@ -331,7 +491,6 @@ function optimizeRoute () {
_optimizeRoute(tech)
}
-// ─── Criteria drag-and-drop ───────────────────────────────────────────────────
const critDragIdx = ref(null)
const critDragOver = ref(null)
function dropCriterion (toIdx) {
@@ -343,37 +502,30 @@ function dropCriterion (toIdx) {
critDragIdx.value = null; critDragOver.value = null
}
-// ─── Board tabs ───────────────────────────────────────────────────────────────
const boardTabs = ref(['Vue principale','Par région'])
const activeTab = ref('Vue principale')
-// ─── Unassign drop handler ────────────────────────────────────────────────────
function onDropUnassign (e) {
e.preventDefault()
if (dragJob.value) { fullUnassign(dragJob.value); dragJob.value = null; dragSrc.value = null }
unassignDropActive.value = false
}
-// ─── Click empty space = deselect all ─────────────────────────────────────────
let _lassoJustEnded = false
function onRootClick (e) {
if (_lassoJustEnded) { _lassoJustEnded = false; return }
const interactive = e.target.closest('.sb-block, .sb-chip, .sb-bottom-row, .sb-bottom-hdr, button, input, select, a, .sb-ctx-menu, .sb-right-panel, .sb-wo-modal, .sb-edit-modal, .sb-criteria-modal, .sb-gps-modal, .sb-modal-overlay, .sb-multi-bar, .sb-toolbar-panel, .sb-header, .sb-bt-checkbox, .sb-res-cell, .sb-bottom-date-sep')
if (interactive) return
if (selectedJob.value || multiSelect.value.length || bottomSelected.size || rightPanel.value) {
- selectedJob.value = null
- multiSelect.value = []
- clearBottomSelect()
- rightPanel.value = null
+ selectedJob.value = null; multiSelect.value = []; clearBottomSelect(); rightPanel.value = null
}
}
-// ─── Keyboard ─────────────────────────────────────────────────────────────────
function onKeyDown (e) {
if (e.key === 'Escape') {
closeCtxMenu(); assistCtx.value = null; techCtx.value = null; assistNoteModal.value = null
moveModalOpen.value = false; resSelectorOpen.value = false; rightPanel.value = null
- timeModal.value = null; woModal.value = null; editModal.value = null
+ timeModal.value = null; woModalOpen.value = false; editModal.value = null
dispatchCriteriaModal.value = false; bookingOverlay.value = null
filterPanelOpen.value = false; projectsPanelOpen.value = false
selectedJob.value = null; multiSelect.value = []
@@ -396,7 +548,6 @@ function onKeyDown (e) {
}
}
-// ─── Refresh ──────────────────────────────────────────────────────────────────
async function refreshData () {
const prevTechId = selectedTechId.value
await store.loadAll()
@@ -412,7 +563,6 @@ async function refreshData () {
await loadPendingReqs()
}
-// ─── Provide for child components ─────────────────────────────────────────────
provide('store', store)
provide('TECH_COLORS', TECH_COLORS)
provide('MAPBOX_TOKEN', MAPBOX_TOKEN)
@@ -425,6 +575,8 @@ provide('onDeleteTag', onDeleteTag)
provide('selectedJob', selectedJob)
provide('hoveredJobId', hoveredJobId)
provide('periodLoadH', periodLoadH)
+provide('techPeriodCapacityH', techPeriodCapacityH)
+provide('techDayEndH', techDayEndH)
provide('isJobMultiSelected', isJobMultiSelected)
provide('btColW', btColW)
provide('startColResize', startColResize)
@@ -432,7 +584,38 @@ provide('searchAddr', searchAddr)
provide('addrResults', addrResults)
provide('selectAddr', selectAddr)
-// ─── Lifecycle ────────────────────────────────────────────────────────────────
+// ── SSE for real-time tech absence updates ──
+const HUB_SSE_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
+let dispatchSse = null
+
+function connectDispatchSSE () {
+ if (dispatchSse) dispatchSse.close()
+ dispatchSse = new EventSource(`${HUB_SSE_URL}/sse?topics=dispatch`)
+
+ dispatchSse.addEventListener('tech-absence', (e) => {
+ try {
+ const data = JSON.parse(e.data)
+ const tech = store.technicians.find(t => t.name === data.techName || t.id === data.techName)
+ if (!tech) return
+ if (data.action === 'set') {
+ tech.status = 'off'
+ tech.absenceFrom = data.from || null
+ tech.absenceUntil = data.until || null
+ tech.absenceStartTime = data.startTime || null
+ tech.absenceEndTime = data.endTime || null
+ tech.absenceReason = data.reason || 'personal'
+ } else if (data.action === 'clear') {
+ tech.status = 'available'
+ tech.absenceFrom = null
+ tech.absenceUntil = null
+ tech.absenceStartTime = null
+ tech.absenceEndTime = null
+ tech.absenceReason = ''
+ }
+ } catch {}
+ })
+}
+
onMounted(async () => {
if (!store.technicians.length) await store.loadAll()
const savedCoords = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
@@ -448,24 +631,35 @@ onMounted(async () => {
l.href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l)
}
store.startGpsTracking()
+ connectDispatchSSE()
})
-onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('click', closeCtxMenu); destroyMap(); store.stopGpsTracking() })
+onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('click', closeCtxMenu); destroyMap(); store.stopGpsTracking(); if (dispatchSse) dispatchSse.close() })
-
-
-
-
{}">
Réservation ✕
-
Titre {{ bookingOverlay.job?.subject }}
-
Adresse {{ bookingOverlay.job?.address }}
-
Durée {{ fmtDur(bookingOverlay.job?.duration) }}
+
+ {{ f[0] }} {{ f[1] }}
+
Priorité {{ prioLabel(bookingOverlay.job?.priority) }}
-
Technicien {{ bookingOverlay.tech?.fullName || '—' }}
Date planifiée {{ bookingOverlay.job?.scheduledDate || '—' }} → {{ bookingOverlay.job.endDate }}
Date de fin
-
Statut {{ bookingOverlay.job?.status }}
Tags
{ bookingOverlay.job.tagsWithLevel = v; bookingOverlay.job.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistJobTags(bookingOverlay.job) }"
@@ -604,12 +810,10 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
-
-
{ document.removeEventListener('keydown', onKeyDown); document
@tech-reorder-start="onTechReorderStart" @tech-reorder-drop="onTechReorderDrop"
@cal-drop="onCalDrop" @job-dragstart="onJobDragStart"
@job-click="selectJob" @job-dblclick="openEditModal" @job-ctx="openCtxMenu"
- @clear-filters="clearFilters" />
+ @clear-filters="clearFilters"
+ @open-absence="openAbsenceModal" @end-absence="endAbsence"
+ @open-schedule="openScheduleModal" />
-
-
Ressources {{ filteredResources.length }}
@@ -653,11 +857,13 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
@job-dblclick="openEditModal" @job-ctx="openCtxMenu"
@assist-ctx="openAssistCtx"
@hover-job="id => hoveredJobId=id" @unhover-job="hoveredJobId=null"
- @block-move="startBlockMove" @block-resize="startResize" />
+ @block-move="startBlockMove" @block-resize="startResize"
+ @absence-resize="startAbsenceResize"
+ @open-absence="openAbsenceModal" @end-absence="endAbsence"
+ @open-schedule="openScheduleModal" />
-
{ document.removeEventListener('keydown', onKeyDown); document
@drop-unassign="(e, type) => { if(type==='over') unassignDropActive=!!dragJob; else if(type==='leave') unassignDropActive=false; else onDropUnassign(e) }" />
-
{}" :style="`width:${mapPanelW}px;min-width:${mapPanelW}px`">
@@ -700,7 +905,6 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
-
{ document.removeEventListener('keydown', onKeyDown); document
-
{}">
📄 Voir détails
↔ Déplacer / Réassigner
@@ -740,7 +943,6 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
✕ Retirer l'assistant
-
{{ multiSelect.length }} sélectionné{{ multiSelect.length>1?'s':'' }}
@@ -754,11 +956,10 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
-