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 @@ + + + + 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 @@