diff --git a/quasar.config.js b/quasar.config.js index 27c5e97..ab6937b 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -18,7 +18,7 @@ module.exports = configure(function (ctx) { // Base path = where the app is deployed under ERPNext // Change this if you move the app to a different path extendViteConf (viteConf) { - viteConf.base = '/assets/dispatch-app/' + viteConf.base = process.env.DEPLOY_BASE || '/assets/dispatch-app/' }, }, diff --git a/src/App.vue b/src/App.vue index 98240ae..52d7252 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,3 +1,88 @@ - + + + + + + + + Dispatch + + Connectez-vous avec votre compte ERPNext + {{ auth.error }} + Utilisateur + + Mot de passe + + Connexion + Serveur: {{ erpUrl || 'same-origin' }} + + + + + + + diff --git a/src/api/auth.js b/src/api/auth.js index 39146ac..3c7b6ea 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -1,26 +1,41 @@ -// ── ERPNext session-cookie auth ────────────────────────────────────────────── -// To swap to JWT or another auth method: -// 1. Replace login() / logout() / getLoggedUser() implementations here. -// 2. The stores/auth.js calls these — no changes needed there. -// ───────────────────────────────────────────────────────────────────────────── +// ── ERPNext auth — token-based for cross-domain, cookie fallback for same-origin import { BASE_URL } from 'src/config/erpnext' -let _csrf = null +const TOKEN_KEY = 'erp_api_token' +const USER_KEY = 'erp_user' -export async function getCSRF () { - if (_csrf) return _csrf - try { - const res = await fetch(BASE_URL + '/', { credentials: 'include' }) - const html = await res.text() - const m = html.match(/csrf_token\s*[:=]\s*['"]([^'"]+)['"]/) - if (m) _csrf = m[1] - } catch { /* ignore */ } - return _csrf +function getStoredToken () { + const t = localStorage.getItem(TOKEN_KEY) + if (t) return t + // Check if we have a session but no token — prompt re-login + return null +} +function getStoredUser () { return localStorage.getItem(USER_KEY) } + +// Build headers with token auth if available +function authHeaders (extra = {}) { + const token = getStoredToken() + const headers = { ...extra } + if (token) headers['Authorization'] = 'token ' + token + return headers } -export function invalidateCSRF () { _csrf = null } +// Fetch wrapper that adds auth +export function authFetch (url, opts = {}) { + const token = getStoredToken() + if (token) { + opts.headers = { ...opts.headers, Authorization: 'token ' + token } + } else { + opts.credentials = 'include' + } + return fetch(url, opts) +} + +export async function getCSRF () { return null } +export function invalidateCSRF () {} export async function login (usr, pwd) { + // 1. Try login to get session + generate API keys const res = await fetch(BASE_URL + '/api/method/login', { method: 'POST', credentials: 'include', @@ -31,29 +46,83 @@ export async function login (usr, pwd) { if (!res.ok || data.exc_type === 'AuthenticationError') { throw new Error(data.message || 'Identifiants incorrects') } - invalidateCSRF() + + // 2. Generate API keys for persistent token auth (required for cross-domain PUT/DELETE) + try { + const genRes = await fetch(BASE_URL + '/api/method/frappe.core.doctype.user.user.generate_keys', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: usr }), + }) + if (genRes.ok) { + const genData = await genRes.json() + const apiKey = genData.message?.api_key + const apiSecret = genData.message?.api_secret + if (apiKey && apiSecret) { + localStorage.setItem(TOKEN_KEY, apiKey + ':' + apiSecret) + } + } + } catch { /* API keys not available — will use session cookies */ } + + localStorage.setItem(USER_KEY, usr) return data } export async function logout () { try { - await fetch(BASE_URL + '/api/method/frappe.auth.logout', { - method: 'POST', - credentials: 'include', - }) + await authFetch(BASE_URL + '/api/method/frappe.auth.logout', { method: 'POST' }) } catch { /* ignore */ } - invalidateCSRF() + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(USER_KEY) } -// Returns email string if logged in, null if guest/error export async function getLoggedUser () { + // Check stored token first + const token = getStoredToken() + if (token) { + try { + const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { + headers: { Authorization: 'token ' + token }, + }) + const data = await res.json() + const user = data.message + if (user && user !== 'Guest') return user + } catch { /* token invalid */ } + // Token failed — clear it + localStorage.removeItem(TOKEN_KEY) + } + + // Fallback to cookie (Authentik SSO, same-origin session) try { const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { credentials: 'include', }) const data = await res.json() const user = data.message - return user && user !== 'Guest' ? user : null + if (!user || user === 'Guest') return null + + // User authenticated via cookie but no API token — generate one + // (required for cross-domain PUT/DELETE from dispatch.gigafibre.ca → erp.gigafibre.ca) + try { + const genRes = await fetch(BASE_URL + '/api/method/frappe.core.doctype.user.user.generate_keys', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user }), + }) + if (genRes.ok) { + const genData = await genRes.json() + const apiKey = genData.message?.api_key + const apiSecret = genData.message?.api_secret + if (apiKey && apiSecret) { + localStorage.setItem(TOKEN_KEY, apiKey + ':' + apiSecret) + console.log('[Auth] API token generated for', user) + } + } + } catch { /* token generation failed — will use cookies */ } + + return user } catch { return null } diff --git a/src/api/dispatch.js b/src/api/dispatch.js index 3e0b1dc..5ed4b55 100644 --- a/src/api/dispatch.js +++ b/src/api/dispatch.js @@ -3,29 +3,25 @@ // Swap BASE_URL in config/erpnext.js to change the target server. // ───────────────────────────────────────────────────────────────────────────── import { BASE_URL } from 'src/config/erpnext' -import { getCSRF } from './auth' +import { authFetch } from './auth' async function apiGet (path) { - const res = await fetch(BASE_URL + path, { credentials: 'include' }) + const res = await authFetch(BASE_URL + path) const data = await res.json() if (data.exc) throw new Error(data.exc) return data } async function apiPut (doctype, name, body) { - const token = await getCSRF() - const res = await fetch( + const res = await authFetch( `${BASE_URL}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, { method: 'PUT', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'X-Frappe-CSRF-Token': token, - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }, ) + if (!res.ok) console.error(`[API] PUT ${doctype}/${name} failed:`, res.status, await res.text().catch(() => '')) const data = await res.json() if (data.exc) throw new Error(data.exc) return data @@ -61,13 +57,11 @@ export async function updateJob (name, payload) { } export async function createJob (payload) { - const token = await getCSRF() - const res = await fetch( + const res = await authFetch( `${BASE_URL}/api/resource/Dispatch%20Job`, { method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': token }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, ) @@ -80,19 +74,39 @@ export async function updateTech (name, payload) { return apiPut('Dispatch Technician', name, payload) } +export async function createTech (payload) { + const res = await authFetch( + `${BASE_URL}/api/resource/Dispatch%20Technician`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }, + ) + const data = await res.json() + if (data.exc) throw new Error(data.exc) + return data.data +} + +export async function deleteTech (name) { + const res = await authFetch( + `${BASE_URL}/api/resource/Dispatch%20Technician/${encodeURIComponent(name)}`, + { method: 'DELETE' }, + ) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + const msg = data._server_messages ? JSON.parse(JSON.parse(data._server_messages)[0]).message : data.exception || 'Delete failed' + throw new Error(msg) + } +} + export async function fetchTags () { const data = await apiGet('/api/resource/Dispatch%20Tag?fields=["name","label","color","category"]&limit=200') return data.data || [] } export async function createTag (label, category = 'Custom', color = '#6b7280') { - const token = await getCSRF() - const res = await fetch( + const res = await authFetch( `${BASE_URL}/api/resource/Dispatch%20Tag`, { method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': token }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, category, color }), }, ) diff --git a/src/api/traccar.js b/src/api/traccar.js index c992eea..a3b6017 100644 --- a/src/api/traccar.js +++ b/src/api/traccar.js @@ -35,16 +35,16 @@ export async function fetchDevices () { } // ── Positions ──────────────────────────────────────────────────────────────── +// Traccar API only supports ONE deviceId per request — fetch in parallel export async function fetchPositions (deviceIds = null) { - let url = TRACCAR_URL + '/api/positions' - if (deviceIds && deviceIds.length) { - url += '?' + deviceIds.map(id => 'deviceId=' + id).join('&') - } - try { - const res = await fetch(url, authOpts()) - if (res.ok) return await res.json() - } catch {} - return [] + if (!deviceIds || !deviceIds.length) return [] + const results = await Promise.allSettled( + deviceIds.map(id => + fetch(TRACCAR_URL + '/api/positions?deviceId=' + id, authOpts()) + .then(r => r.ok ? r.json() : []) + ) + ) + return results.flatMap(r => r.status === 'fulfilled' ? r.value : []) } // ── Get position for a specific device ─────────────────────────────────────── @@ -78,4 +78,17 @@ export function matchDeviceToTech (devices, techs) { return matched } +// ── Session (required for WebSocket auth) ──────────────────────────────────── +export async function createTraccarSession () { + try { + const res = await fetch(TRACCAR_URL + '/api/session', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ email: TRACCAR_USER, password: TRACCAR_PASS }), + }) + return res.ok + } catch { return false } +} + export { TRACCAR_URL, _devices as cachedDevices } diff --git a/src/composables/useAutoDispatch.js b/src/composables/useAutoDispatch.js index 90c98d0..214d3a4 100644 --- a/src/composables/useAutoDispatch.js +++ b/src/composables/useAutoDispatch.js @@ -15,7 +15,11 @@ export function useAutoDispatch (deps) { } else { pool = unscheduledJobs.value.filter(j => !j.scheduledDate || j.scheduledDate === today) } - const unassigned = pool.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) + if (!pool.length) return + // Jobs with coords get proximity-based assignment, jobs without get load-balanced only + const withCoords = pool.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) + const noCoords = pool.filter(j => !j.coords || (j.coords[0] === 0 && j.coords[1] === 0)) + const unassigned = [...withCoords, ...noCoords] if (!unassigned.length) return const prevQueues = {} @@ -58,7 +62,7 @@ export function useAutoDispatch (deps) { techs.forEach(tech => { let score = 0 if (weights.balance) score += techLoadForDay(tech, assignDay) * (weights.balance || 1) - if (weights.proximity) score += dist(techLastPosForDay(tech, assignDay), job.coords) / 60 * (weights.proximity || 1) + if (weights.proximity && job.coords && (job.coords[0] !== 0 || job.coords[1] !== 0)) score += dist(techLastPosForDay(tech, assignDay), job.coords) / 60 * (weights.proximity || 1) if (weights.skills && useSkills) { const jt = job.tags || [], tt = tech.tags || [] score += (jt.length > 0 ? (jt.length - jt.filter(t => tt.includes(t)).length) * 2 : 0) * (weights.skills || 1) diff --git a/src/composables/useDragDrop.js b/src/composables/useDragDrop.js index 222a265..0af0b1d 100644 --- a/src/composables/useDragDrop.js +++ b/src/composables/useDragDrop.js @@ -6,7 +6,7 @@ import { updateJob } from 'src/api/dispatch' export function useDragDrop (deps) { const { store, pxPerHr, dayW, periodStart, periodDays, H_START, - getJobDate, bottomSelected, + getJobDate, bottomSelected, multiSelect, pushUndo, smartAssign, invalidateRoutes, } = deps @@ -70,15 +70,28 @@ export function useDragDrop (deps) { dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return } if (dragBatchIds.value && dragBatchIds.value.size > 1) { + const prevStates = [] dragBatchIds.value.forEach(jobId => { const j = store.jobs.find(x => x.id === jobId) if (j && !j.assignedTech) { - pushUndo({ type: 'unassignJob', jobId: j.id, techId: j.assignedTech, routeOrder: j.routeOrder, scheduledDate: j.scheduledDate, assistants: [...(j.assistants || [])] }) + prevStates.push({ jobId: j.id, techId: j.assignedTech, routeOrder: j.routeOrder, scheduledDate: j.scheduledDate, assistants: [...(j.assistants || [])] }) smartAssign(j, tech.id, dateStr) } }) + if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, targetTechId: tech.id }) bottomSelected.value = new Set() dragBatchIds.value = null + } else if (multiSelect && multiSelect.value?.length > 1 && multiSelect.value.some(s => s.job.id === dragJob.value.id)) { + // Dragging a multi-selected block from timeline — move all selected + const prevStates = [] + const prevQueues = {} + store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] }) + multiSelect.value.filter(s => !s.isAssist).forEach(s => { + prevStates.push({ jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder, scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])] }) + smartAssign(s.job, tech.id, dateStr) + }) + if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, prevQueues }) + multiSelect.value = [] } else { const job = dragJob.value pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] }) diff --git a/src/composables/useSelection.js b/src/composables/useSelection.js index ee2ad3b..3e3a922 100644 --- a/src/composables/useSelection.js +++ b/src/composables/useSelection.js @@ -37,21 +37,46 @@ export function useSelection (deps) { return multiSelect.value.some(s => s.job.id === jobId && s.isAssist === isAssist) } - // ── Batch ops ───────────────────────────────────────────────────────────────── - function batchUnassign () { + // ── Batch ops (grouped undo) ────────────────────────────────────────────────── + function batchUnassign (pushUndo) { if (!multiSelect.value.length) return + // Snapshot all jobs before unassign — single undo entry + const assignments = multiSelect.value.filter(s => !s.isAssist).map(s => ({ + jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder, + scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])] + })) + const prevQueues = {} + store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] }) + multiSelect.value.forEach(s => { if (s.isAssist && s.assistTechId) store.removeAssistant(s.job.id, s.assistTechId) - else fullUnassign(s.job) + else store.fullUnassign(s.job.id) }) + + if (pushUndo && assignments.length) { + pushUndo({ type: 'batchAssign', assignments, prevQueues }) + } multiSelect.value = []; selectedJob.value = null invalidateRoutes() } - function batchMoveTo (techId) { + function batchMoveTo (techId, dayStr, pushUndo) { if (!multiSelect.value.length) return - const dayStr = localDateStr(periodStart.value) - multiSelect.value.filter(s => !s.isAssist).forEach(s => smartAssign(s.job, techId, dayStr)) + const day = dayStr || localDateStr(periodStart.value) + const jobs = multiSelect.value.filter(s => !s.isAssist) + // Snapshot for grouped undo + const assignments = jobs.map(s => ({ + jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder, + scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])] + })) + const prevQueues = {} + store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] }) + + jobs.forEach(s => smartAssign(s.job, techId, day)) + + if (pushUndo && assignments.length) { + pushUndo({ type: 'batchAssign', assignments, prevQueues }) + } multiSelect.value = []; selectedJob.value = null invalidateRoutes() } diff --git a/src/composables/useUndo.js b/src/composables/useUndo.js index ac35346..24de03b 100644 --- a/src/composables/useUndo.js +++ b/src/composables/useUndo.js @@ -1,6 +1,7 @@ // ── Undo stack composable ──────────────────────────────────────────────────── import { ref, nextTick } from 'vue' import { updateJob } from 'src/api/dispatch' +import { serializeAssistants } from './useHelpers' export function useUndo (store, invalidateRoutes) { const undoStack = ref([]) @@ -10,6 +11,28 @@ export function useUndo (store, invalidateRoutes) { if (undoStack.value.length > 30) undoStack.value.shift() } + // Restore a single job to its previous state (unassign from current tech, re-assign if it had one) + function _restoreJob (prev) { + const job = store.jobs.find(j => j.id === prev.jobId) + if (!job) return + // Remove from all tech queues first + store.technicians.forEach(t => { t.queue = t.queue.filter(q => q.id !== prev.jobId) }) + if (prev.techId) { + // Was assigned before — re-assign + store.assignJobToTech(prev.jobId, prev.techId, prev.routeOrder, prev.scheduledDate) + } else { + // Was unassigned before — just mark as open + job.assignedTech = null + job.status = 'open' + job.scheduledDate = prev.scheduledDate || null + updateJob(job.name || job.id, { assigned_tech: null, status: 'open', scheduled_date: prev.scheduledDate || '' }).catch(() => {}) + } + if (prev.assistants?.length) { + job.assistants = prev.assistants + updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) + } + } + function performUndo () { const action = undoStack.value.pop() if (!action) return @@ -20,46 +43,34 @@ export function useUndo (store, invalidateRoutes) { const job = store.jobs.find(j => j.id === action.jobId) const a = job?.assistants.find(x => x.techId === action.techId) if (a) { a.duration = action.duration; a.note = action.note } - updateJob(job.name || job.id, { - assistants: job.assistants.map(x => ({ tech_id: x.techId, tech_name: x.techName, duration_h: x.duration, note: x.note || '', pinned: x.pinned ? 1 : 0 })), - }).catch(() => {}) + updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) }) + } else if (action.type === 'optimizeRoute') { const tech = store.technicians.find(t => t.id === action.techId) if (tech) { tech.queue = action.prevQueue action.prevQueue.forEach((j, i) => { j.routeOrder = i }) } + } else if (action.type === 'autoDistribute') { - // Unassign all the jobs that were auto-assigned - action.assignments.forEach(a => { - const job = store.jobs.find(j => j.id === a.jobId) - if (job) { - store.technicians.forEach(t => { t.queue = t.queue.filter(q => q.id !== a.jobId) }) - job.assignedTech = a.techId || null - job.status = a.techId ? 'assigned' : 'open' - job.scheduledDate = a.scheduledDate - } - }) - // Restore original queues + action.assignments.forEach(a => _restoreJob(a)) if (action.prevQueues) { store.technicians.forEach(t => { if (action.prevQueues[t.id]) t.queue = action.prevQueues[t.id] }) } + + } else if (action.type === 'batchAssign') { + // Undo a multi-select drag — restore each job to previous state + action.assignments.forEach(a => _restoreJob(a)) + } else if (action.type === 'unassignJob') { - store.assignJobToTech(action.jobId, action.techId, action.routeOrder, action.scheduledDate) - nextTick(() => { - const job = store.jobs.find(j => j.id === action.jobId) - if (job && action.assistants?.length) { - job.assistants = action.assistants - updateJob(job.name || job.id, { - assistants: job.assistants.map(x => ({ tech_id: x.techId, tech_name: x.techName, duration_h: x.duration, note: x.note || '', pinned: x.pinned ? 1 : 0 })), - }).catch(() => {}) - } - }) + _restoreJob(action) } + // Rebuild assistJobs on all techs + store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) }) invalidateRoutes() } diff --git a/src/config/erpnext.js b/src/config/erpnext.js index 2ec73ac..0323967 100644 --- a/src/config/erpnext.js +++ b/src/config/erpnext.js @@ -5,7 +5,9 @@ // - Update api/auth.js if switching from session cookie to JWT // For same-origin (ERPNext serves the app): keep BASE_URL as empty string. -export const BASE_URL = '' +// In production, /api/ is proxied to ERPNext via nginx (same-origin, no CORS) +// In dev (localhost), calls go directly to ERPNext +export const BASE_URL = window.location.hostname === 'localhost' ? 'https://erp.gigafibre.ca' : '' // Mapbox public token — safe to expose (scope-limited in Mapbox dashboard) export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg' diff --git a/src/modules/dispatch/components/BottomPanel.vue b/src/modules/dispatch/components/BottomPanel.vue index 1f3886b..0bc9b42 100644 --- a/src/modules/dispatch/components/BottomPanel.vue +++ b/src/modules/dispatch/components/BottomPanel.vue @@ -1,5 +1,5 @@ @@ -70,7 +137,7 @@ const startColResize = inject('startColResize') - + {{ group.label }} @@ -80,8 +147,9 @@ const startColResize = inject('startColResize') 1)" @click="emit('row-click', job, $event)" @dblclick.stop="emit('row-dblclick', job)"> @@ -114,6 +182,12 @@ const startColResize = inject('startColResize') Aucune job non assignée + diff --git a/src/pages/DispatchV2Page.vue b/src/pages/DispatchV2Page.vue index a6ab582..c145cdd 100644 --- a/src/pages/DispatchV2Page.vue +++ b/src/pages/DispatchV2Page.vue @@ -1,7 +1,8 @@ - + - Dispatch + Dispatch {{ tab }} @@ -613,8 +703,14 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document 🗺 Carte ↻ + 📡 + WO - + + {{ auth.user }} + ERP + ⏻ + + @@ -731,7 +827,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document - + @@ -788,9 +884,11 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document :selected="bottomSelected" :drop-active="unassignDropActive" @update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize" @toggle-select="toggleBottomSelect" @select-all="selectAllBottom" @clear-select="clearBottomSelect" + @lasso-select="ids => { const s = new Set(bottomSelected); ids.forEach(id => s.add(id)); bottomSelected = s }" + @deselect-all="() => { clearBottomSelect(); selectedJob = null; multiSelect = []; rightPanel = null }" @batch-assign="batchAssignBottom" @auto-distribute="autoDistribute" @open-criteria="dispatchCriteriaModal=true" - @row-click="(job) => rightPanel={ mode:'details', data:{ job, tech:null } }" + @row-click="(job, ev) => { if(ev?.shiftKey || ev?.ctrlKey || ev?.metaKey) { toggleBottomSelect(job.id, ev) } else { rightPanel={ mode:'details', data:{ job, tech:null } } } }" @row-dblclick="openEditModal" @row-dragstart="(e, job) => onJobDragStart(e, job, null)" @drop-unassign="(e, type) => { if(type==='over') unassignDropActive=!!dragJob; else if(type==='leave') unassignDropActive=false; else onDropUnassign(e) }" /> @@ -867,10 +965,10 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document {{ multiSelect.length }} sélectionné{{ multiSelect.length>1?'s':'' }} - ✕ Désaffecter + ✕ Désaffecter | Déplacer vers : - + {{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase() }} Annuler @@ -987,6 +1085,91 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document + + + + + 📡 GPS Tracking — Traccar + × + + + Gérer les techniciens, associer les devices Traccar et suivre le GPS en temps réel. + + + + Technicien + Téléphone + Statut + Device GPS + GPS + + + + + + + + {{ tech.fullName }} + + + + + + + Disponible + Occupé + Absent + + + + + — Non lié — + + {{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }} + + + + + + En ligne · {{ (tech.gpsSpeed * 1.852).toFixed(0) }}km/h + + Hors ligne + — + + × + + + + + + + + + — Aucun — + + {{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }} + + + + + + {{ addingTech ? '...' : '+ Ajouter' }} + + + + + + + + + + @@ -1028,6 +1211,12 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; } .sb-search:focus { border-color:var(--sb-border-acc); } .sb-icon-btn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-muted); font-size:0.68rem; font-weight:600; padding:0.22rem 0.55rem; cursor:pointer; white-space:nowrap; transition:color 0.12s, border-color 0.12s; } .sb-icon-btn:hover, .sb-icon-btn.active { color:var(--sb-text); border-color:var(--sb-border-acc); background:var(--sb-card); } +.sb-user-menu { display:flex; align-items:center; gap:6px; } +.sb-user-name { font-size:11px; color:var(--sb-muted); max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.sb-erp-link { font-size:10px; padding:2px 6px; background:rgba(99,102,241,.15); color:var(--sb-accent); border-radius:4px; text-decoration:none; font-weight:600; } +.sb-erp-link:hover { background:rgba(99,102,241,.3); } +.sb-logout-btn { font-size:14px !important; opacity:.6; } +.sb-logout-btn:hover { opacity:1; color:var(--sb-red); } .sb-erp-dot { width:7px; height:7px; border-radius:50%; background:var(--sb-red); transition:background 0.3s; } .sb-erp-dot.ok { background:var(--sb-green); } .sb-wo-btn { background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:0.7rem; font-weight:800; padding:0.22rem 0.65rem; cursor:pointer; white-space:nowrap; } @@ -1216,13 +1405,13 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; } .sb-bottom-title { font-size:0.62rem; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--sb-muted); display:flex; align-items:center; gap:0.35rem; } .sb-bottom-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.9rem; margin-left:auto; } .sb-bottom-close:hover { color:var(--sb-red); } -.sb-bottom-body { flex:1; overflow-y:auto; overflow-x:auto; } +.sb-bottom-body { flex:1; overflow-y:auto; overflow-x:auto; display:flex; flex-direction:column; } .sb-bottom-body::-webkit-scrollbar { width:4px; height:4px; } .sb-bottom-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); } -.sb-bottom-date-sep { display:flex; align-items:center; gap:0.4rem; padding:0.3rem 0.75rem; background:rgba(99,102,241,0.06); border-bottom:1px solid var(--sb-border); position:sticky; top:0; z-index:2; } +.sb-bottom-date-sep { display:flex; align-items:center; gap:0.4rem; padding:0.3rem 0.75rem; background:rgba(99,102,241,0.06); border-bottom:1px solid var(--sb-border); position:sticky; top:0; z-index:2; user-select:none; cursor:crosshair; } .sb-bottom-date-label { font-size:0.62rem; font-weight:800; color:var(--sb-acc); text-transform:uppercase; letter-spacing:0.05em; } .sb-bottom-date-count { font-size:0.55rem; color:var(--sb-muted); } -.sb-bottom-scroll { flex:1; overflow-y:auto; overflow-x:hidden; } +.sb-bottom-scroll { flex:1; overflow-y:auto; overflow-x:hidden; min-height:0; cursor:crosshair; } .sb-bottom-scroll::-webkit-scrollbar { width:4px; } .sb-bottom-scroll::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); } .sb-bottom-table { width:100%; border-collapse:collapse; font-size:0.72rem; table-layout:fixed; } @@ -1233,6 +1422,8 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; } .sb-bottom-row:hover { background:var(--sb-card-h); } .sb-bottom-row-sel { background:rgba(99,102,241,0.1) !important; } .sb-bottom-row-sel:hover { background:rgba(99,102,241,0.15) !important; } +.sb-bt-lasso { position:absolute; border:1.5px dashed #f59e0b; background:rgba(245,158,11,0.08); pointer-events:none; z-index:50; border-radius:3px; } +.sb-bottom-scroll:has(.sb-bt-lasso) { user-select:none; -webkit-user-select:none; cursor:crosshair; } .sb-bottom-row td { padding:0.3rem 0.5rem; white-space:nowrap; color:var(--sb-text); overflow:hidden; text-overflow:ellipsis; } .sb-bt-chk { padding:0 !important; text-align:center !important; } .sb-bt-checkbox { display:inline-block; width:14px; height:14px; border-radius:3px; border:1.5px solid var(--sb-muted); vertical-align:middle; position:relative; } @@ -1306,6 +1497,8 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; } .sb-map { flex:1; min-height:0; } .sb-map-tech-pin { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:800; color:#fff; border:2.5px solid; box-shadow:0 2px 10px rgba(0,0,0,0.55); cursor:pointer; transition:transform 0.15s; } .sb-map-tech-pin:hover { transform:scale(1.2); } +.sb-map-gps-active { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); animation:gps-glow 2s infinite; } +@keyframes gps-glow { 0%,100% { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); } 50% { box-shadow:0 0 0 6px rgba(16,185,129,0.3), 0 0 20px rgba(16,185,129,0.3), 0 2px 10px rgba(0,0,0,0.55); } } .sb-map-drag-ghost { padding:4px 8px; border-radius:6px; background:rgba(99,102,241,0.9); color:#fff; font-size:0.68rem; font-weight:700; box-shadow:0 4px 16px rgba(0,0,0,0.55); max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; user-select:none; } /* ── Right panel ── */ @@ -1340,10 +1533,12 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; } /* ── Modals ── */ .sb-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.65); backdrop-filter:blur(4px); z-index:100; display:flex; align-items:center; justify-content:center; } -.sb-modal { background:#111422; color:#e2e4ef; border:1px solid rgba(255,255,255,0.06); border-radius:14px; padding:0; min-width:360px; max-width:500px; width:100%; box-shadow:0 24px 60px rgba(0,0,0,0.6); overflow:hidden; } +.sb-modal { background:#111422; color:#e2e4ef; border:1px solid rgba(255,255,255,0.06); border-radius:14px; padding:0; min-width:360px; max-width:500px; width:100%; box-shadow:0 24px 60px rgba(0,0,0,0.6); overflow:hidden; max-height:85vh; display:flex; flex-direction:column; } .sb-modal-wide { min-width:580px; max-width:680px; } .sb-modal-hdr { display:flex; align-items:center; justify-content:space-between; padding:0.75rem 1rem; border-bottom:1px solid rgba(255,255,255,0.06); font-weight:700; font-size:0.85rem; color:#e2e4ef; } -.sb-modal-body { padding:0.75rem 1rem; color:#e2e4ef; } +.sb-modal-body { padding:0.75rem 1rem; color:#e2e4ef; overflow-y:auto; flex:1; min-height:0; } +.sb-modal-body::-webkit-scrollbar { width:4px; } +.sb-modal-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); border-radius:2px; } .sb-modal-ftr { padding:0.65rem 1rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; gap:0.5rem; } .sb-modal-footer { padding:0.65rem 1rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; gap:0.5rem; } .sb-form-row { margin-bottom:0.6rem; } @@ -1382,4 +1577,47 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; } .sb-slide-left-enter-from, .sb-slide-left-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; } .sb-slide-right-enter-active, .sb-slide-right-leave-active { transition:width 0.18s, min-width 0.18s, opacity 0.18s; } .sb-slide-right-enter-from, .sb-slide-right-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; } +/* GPS Settings Modal */ +.sb-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; display:flex; align-items:center; justify-content:center; } +.sb-gps-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:12px; width:700px; max-height:80vh; overflow:hidden; display:flex; flex-direction:column; } +.sb-gps-modal-hdr { padding:14px 20px; border-bottom:1px solid var(--sb-border); display:flex; align-items:center; justify-content:space-between; } +.sb-gps-modal-hdr h3 { font-size:15px; margin:0; } +.sb-gps-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:20px; } +.sb-gps-modal-body { padding:16px 20px; overflow-y:auto; } +.sb-gps-desc { color:var(--sb-muted); font-size:12px; margin-bottom:14px; } +.sb-gps-table { width:100%; border-collapse:collapse; } +.sb-gps-table th { text-align:left; font-size:11px; text-transform:uppercase; color:var(--sb-muted); padding:6px 8px; border-bottom:1px solid var(--sb-border); } +.sb-gps-table td { padding:8px; border-bottom:1px solid var(--sb-border); font-size:13px; } +.sb-gps-select { width:100%; padding:5px 8px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:5px; color:var(--sb-text); font-size:12px; } +.sb-gps-badge { padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; } +.sb-gps-online { background:rgba(16,185,129,0.15); color:#10b981; } +.sb-gps-offline { background:rgba(245,158,11,0.15); color:#f59e0b; } +.sb-gps-none { background:rgba(107,114,128,0.15); color:#6b7280; } +.sb-gps-coords { font-size:11px; color:var(--sb-muted); font-family:monospace; } +.sb-gps-footer { display:flex; align-items:center; justify-content:space-between; margin-top:12px; padding-top:12px; border-top:1px solid var(--sb-border); } +.sb-gps-info { font-size:11px; color:var(--sb-muted); } +.sb-gps-refresh { padding:5px 12px; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:12px; cursor:pointer; } +.sb-gps-input { width:100%; padding:4px 8px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-fg); font-size:12px; } +.sb-gps-add-row td { padding-top:8px; border-top:1px solid var(--sb-border); } +.sb-gps-add-btn { padding:4px 14px; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:12px; cursor:pointer; white-space:nowrap; } +.sb-gps-add-btn:disabled { opacity:.5; cursor:not-allowed; } +.sb-gps-del-btn { background:none; border:none; color:var(--sb-muted); font-size:16px; cursor:pointer; padding:0 4px; line-height:1; } +.sb-gps-del-btn:hover { color:#f43f5e; } +.sb-gps-editable { cursor:pointer; border-bottom:1px dashed transparent; } +.sb-gps-editable:hover { border-bottom-color:var(--sb-muted); } +.sb-gps-edit-name { font-weight:600; } +.sb-gps-status-sel { min-width:100px; } +.sb-gps-phone { width:110px; font-variant-numeric:tabular-nums; } +/* ── Login Overlay ── */ +.sb-login-overlay { position:fixed; inset:0; background:var(--sb-bg); z-index:9999; display:flex; align-items:center; justify-content:center; } +.sb-login-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:14px; padding:40px 36px; width:340px; display:flex; flex-direction:column; align-items:center; gap:16px; box-shadow:0 8px 40px rgba(0,0,0,0.6); } +.sb-login-logo { font-size:1.6rem; font-weight:800; color:var(--sb-acc); letter-spacing:-0.5px; } +.sb-login-sub { font-size:11px; color:var(--sb-muted); margin:0; text-align:center; } +.sb-login-sub a { color:var(--sb-acc); text-decoration:none; } +.sb-login-form { width:100%; display:flex; flex-direction:column; gap:10px; } +.sb-login-input { width:100%; padding:9px 12px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:7px; color:var(--sb-text); font-size:13px; outline:none; box-sizing:border-box; } +.sb-login-input:focus { border-color:var(--sb-acc); } +.sb-login-btn { width:100%; padding:10px; background:var(--sb-acc); border:none; border-radius:7px; color:#fff; font-size:13px; font-weight:600; cursor:pointer; transition:opacity 0.15s; } +.sb-login-btn:disabled { opacity:0.6; cursor:not-allowed; } +.sb-login-error { color:var(--sb-red); font-size:12px; margin:0; text-align:center; } diff --git a/src/router/index.js b/src/router/index.js index b327bb9..7f5283d 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -3,13 +3,9 @@ import { createRouter, createWebHashHistory } from 'vue-router' // Routes — add pages here; no change needed in stores or API const routes = [ - { path: '/', component: () => import('pages/DispatchPage.vue') }, + { path: '/', component: () => import('pages/DispatchV2Page.vue') }, { path: '/mobile', component: () => import('pages/MobilePage.vue') }, { path: '/admin', component: () => import('pages/AdminPage.vue') }, - { path: '/booking', component: () => import('pages/BookingPage.vue') }, - { path: '/bid', component: () => import('pages/TechBidPage.vue') }, - { path: '/contractor', component: () => import('pages/ContractorPage.vue') }, - { path: '/dispatch2', component: () => import('pages/DispatchV2Page.vue') }, ] export default route(function () { diff --git a/src/stores/auth.js b/src/stores/auth.js index 2e19afd..9d6cb3b 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -8,13 +8,16 @@ import { login, logout, getLoggedUser } from 'src/api/auth' export const useAuthStore = defineStore('auth', () => { const user = ref(null) // email string when logged in, null when guest - const loading = ref(false) + const loading = ref(true) // true until first checkSession() completes const error = ref('') async function checkSession () { loading.value = true - user.value = await getLoggedUser() - loading.value = false + try { + user.value = await getLoggedUser() + } finally { + loading.value = false + } } async function doLogin (usr, pwd) { diff --git a/src/stores/dispatch.js b/src/stores/dispatch.js index 2b327b9..27670fd 100644 --- a/src/stores/dispatch.js +++ b/src/stores/dispatch.js @@ -4,11 +4,16 @@ // ───────────────────────────────────────────────────────────────────────────── import { defineStore } from 'pinia' import { ref } from 'vue' -import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags } from 'src/api/dispatch' -import { fetchDevices, fetchPositions } from 'src/api/traccar' +import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch' +import { fetchDevices, fetchPositions, createTraccarSession } from 'src/api/traccar' import { TECH_COLORS } from 'src/config/erpnext' import { serializeAssistants } from 'src/composables/useHelpers' +// Module-level GPS guards — survive store re-creation and component remount +let __gpsStarted = false +let __gpsInterval = null +let __gpsPolling = false + export const useDispatchStore = defineStore('dispatch', () => { const technicians = ref([]) const jobs = ref([]) @@ -54,6 +59,8 @@ export const useDispatchStore = defineStore('dispatch', () => { gpsTime: null, gpsOnline: false, traccarDeviceId: t.traccar_device_id || null, + phone: t.phone || '', + email: t.email || '', queue: [], // filled in loadAll() tags: (t.tags || []).map(tg => tg.tag), } @@ -270,53 +277,133 @@ export const useDispatchStore = defineStore('dispatch', () => { technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) }) } - // ── Traccar GPS polling ────────────────────────────────────────────────── + // ── Traccar GPS — Hybrid: REST initial + WebSocket real-time ───────────────── const traccarDevices = ref([]) - let _gpsInterval = null + const _techsByDevice = {} // deviceId (number) → tech object + function _buildTechDeviceMap () { + Object.keys(_techsByDevice).forEach(k => delete _techsByDevice[k]) + technicians.value.forEach(t => { + if (!t.traccarDeviceId) return + const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId) + if (dev) _techsByDevice[dev.id] = t + }) + } + + function _applyPositions (positions) { + positions.forEach(p => { + const tech = _techsByDevice[p.deviceId] + if (!tech || !p.latitude || !p.longitude) return + const cur = tech.gpsCoords + if (!cur || Math.abs(cur[0] - p.longitude) > 0.00001 || Math.abs(cur[1] - p.latitude) > 0.00001) { + tech.gpsCoords = [p.longitude, p.latitude] + } + tech.gpsSpeed = p.speed || 0 + tech.gpsTime = p.fixTime + tech.gpsOnline = true + }) + } + + // One-shot REST fetch (manual refresh button + initial load) async function pollGps () { + if (__gpsPolling) return + __gpsPolling = true try { if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices() - console.log('[GPS] Devices loaded:', traccarDevices.value.length) - // Build map of traccarDeviceId → tech - const techsByDevice = {} - technicians.value.forEach(t => { - if (t.traccarDeviceId) { - const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId) - if (dev) { techsByDevice[dev.id] = t; console.log('[GPS] Matched', t.fullName, '→ device', dev.id, dev.name) } - else console.log('[GPS] No device match for', t.fullName, 'traccarId:', t.traccarDeviceId) - } - }) - const deviceIds = Object.keys(techsByDevice).map(Number) - if (!deviceIds.length) { console.log('[GPS] No devices to poll'); return } - console.log('[GPS] Fetching positions for devices:', deviceIds) + _buildTechDeviceMap() + const deviceIds = Object.keys(_techsByDevice).map(Number) + if (!deviceIds.length) return const positions = await fetchPositions(deviceIds) - console.log('[GPS] Got', positions.length, 'positions') - positions.forEach(p => { - const tech = techsByDevice[p.deviceId] - if (tech && p.latitude && p.longitude) { - tech.gpsCoords = [p.longitude, p.latitude] - tech.gpsSpeed = p.speed || 0 - tech.gpsTime = p.fixTime - tech.gpsOnline = true - console.log('[GPS]', tech.fullName, 'updated:', p.latitude.toFixed(4), p.longitude.toFixed(4)) - } - }) - // Mark techs with no position as offline - Object.values(techsByDevice).forEach(t => { - if (!positions.find(p => techsByDevice[p.deviceId] === t)) t.gpsOnline = false + _applyPositions(positions) + Object.values(_techsByDevice).forEach(t => { + if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false }) } catch (e) { console.warn('[GPS] Poll error:', e.message) } + finally { __gpsPolling = false } } - function startGpsPolling (intervalMs = 30000) { - if (_gpsInterval) return - pollGps() // immediate first poll - _gpsInterval = setInterval(pollGps, intervalMs) + // WebSocket connection with auto-reconnect + 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 (e) { console.warn('[GPS] WS error:', e); return } + __ws.onopen = () => { + __wsBackoff = 1000 + // WS connected — stop fallback polling + if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null } + console.log('[GPS] WebSocket connected — real-time updates active') + } + __ws.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.positions?.length) { + _buildTechDeviceMap() // refresh map in case techs changed + _applyPositions(data.positions) + } + } catch {} + } + __ws.onerror = () => {} + __ws.onclose = () => { + __ws = null + if (!__gpsStarted) return + // Start fallback polling while WS is down + if (!__gpsInterval) { + __gpsInterval = setInterval(pollGps, 30000) + console.log('[GPS] WS closed — fallback to 30s polling') + } + setTimeout(_connectWs, __wsBackoff) + __wsBackoff = Math.min(__wsBackoff * 2, 60000) + } } - function stopGpsPolling () { - if (_gpsInterval) { clearInterval(_gpsInterval); _gpsInterval = null } + async function startGpsTracking () { + if (__gpsStarted) return + __gpsStarted = true + // 1. Load devices + initial REST fetch (all last-known positions) + await pollGps() + console.log('[GPS] Initial positions loaded via REST') + // 2. Create session cookie for WebSocket auth, then connect + const sessionOk = await createTraccarSession() + if (sessionOk) { + _connectWs() + } else { + // Session failed — fall back to polling + __gpsInterval = setInterval(pollGps, 30000) + console.log('[GPS] Session failed — fallback to 30s polling') + } + } + + function stopGpsTracking () { + if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null } + if (__ws) { const ws = __ws; __ws = null; ws.onclose = null; ws.close() } + } + + const startGpsPolling = startGpsTracking + const stopGpsPolling = stopGpsTracking + + // ── Create / Delete technician ───────────────────────────────────────────── + async function createTechnician (fields) { + // Auto-generate technician_id: TECH-N+1 + const maxNum = technicians.value.reduce((max, t) => { + const m = (t.id || '').match(/TECH-(\d+)/) + return m ? Math.max(max, parseInt(m[1])) : max + }, 0) + fields.technician_id = 'TECH-' + (maxNum + 1) + const doc = await apiCreateTech(fields) + const tech = _mapTech(doc, technicians.value.length) + technicians.value.push(tech) + return tech + } + + async function deleteTechnician (techId) { + const tech = technicians.value.find(t => t.id === techId) + if (!tech) return + await apiDeleteTech(tech.name) + technicians.value = technicians.value.filter(t => t.id !== techId) } return { @@ -324,6 +411,7 @@ export const useDispatchStore = defineStore('dispatch', () => { loadAll, loadJobsForTech, setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant, smartAssign, fullUnassign, - pollGps, startGpsPolling, stopGpsPolling, + pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling, + createTechnician, deleteTechnician, } })
Connectez-vous avec votre compte ERPNext
Serveur: {{ erpUrl || 'same-origin' }}
Gérer les techniciens, associer les devices Traccar et suivre le GPS en temps réel.