feat: auth gate, GPS hybrid tracking, tech CRUD modal, ERPNext API proxy

Authentication:
- Add App.vue login gate (v-if auth.loading / v-else-if !auth.user / router-view)
- Fix auth.checkSession with try/finally to always reset loading
- Fix generate_keys method name for Frappe v16 (generate_keys, not generate_keys_for_api_user)
- Auto-generate API token on cookie-based auth (Authentik SSO support)
- Remove duplicate checkSession from DispatchV2Page (was causing infinite mount/unmount loop)

GPS Tracking — Hybrid REST + WebSocket:
- Initial REST fetch per-device in parallel (Traccar API only supports one deviceId per request)
- WebSocket real-time updates via wss://dispatch.gigafibre.ca/traccar/api/socket
- Auto-fallback to 30s polling if WebSocket fails, with exponential backoff reconnect
- Module-level guards (__gpsStarted, __gpsPolling) to prevent loops on component remount
- Only update tech.gpsCoords when value actually changes (prevents unnecessary reactive triggers)

Tech Management (GPS Modal):
- Add/delete technicians directly from GPS modal → persists to ERPNext
- Inline edit: double-click name to rename, phone field, status select
- Auto-generate technician_id (TECH-N+1)
- Unlink jobs before delete to avoid ERPNext LinkExistsError
- Added phone/email custom fields to Dispatch Technician doctype

Infrastructure:
- Nginx proxy: /api/ → ERPNext (same-origin, eliminates all CORS issues)
- Nginx proxy: /traccar/ WebSocket support (Upgrade headers, 86400s timeout)
- No-cache headers on index.html and sw.js for instant PWA updates
- BASE_URL switched to empty string in production (same-origin via proxy)

Bug fixes:
- ERPNext Number Card PostgreSQL fix (ORDER BY on aggregate queries)
- Traccar fetchPositions: parallel per-device calls (API ignores multiple deviceId params)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-03-27 12:02:04 -04:00
parent f1badea201
commit af42c6082e
15 changed files with 789 additions and 154 deletions

View File

@ -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/'
},
},

View File

@ -1,3 +1,88 @@
<template>
<router-view />
<div v-if="auth.loading" class="login-overlay">
<div class="login-spinner"></div>
</div>
<div v-else-if="!auth.user" class="login-overlay">
<div class="login-card">
<div class="login-header">
<svg viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" width="32" height="32"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<h1>Dispatch</h1>
</div>
<p class="login-sub">Connectez-vous avec votre compte ERPNext</p>
<div v-if="auth.error" class="login-error">{{ auth.error }}</div>
<label>Utilisateur</label>
<input v-model="usr" type="text" placeholder="Administrator" @keydown.enter="doLogin" autofocus>
<label>Mot de passe</label>
<input v-model="pwd" type="password" placeholder="Mot de passe" @keydown.enter="doLogin">
<button @click="doLogin" :disabled="!usr || !pwd">Connexion</button>
<p class="login-erp">Serveur: {{ erpUrl || 'same-origin' }}</p>
</div>
</div>
<router-view v-else />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth'
import { BASE_URL } from 'src/config/erpnext'
const auth = useAuthStore()
const usr = ref('')
const pwd = ref('')
const erpUrl = ref(BASE_URL)
onMounted(() => auth.checkSession())
function doLogin () {
if (usr.value && pwd.value) auth.doLogin(usr.value, pwd.value)
}
</script>
<style>
.login-overlay {
position: fixed; inset: 0; background: #0f172a;
display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
z-index: 9999;
}
.login-card {
background: #1e293b; border: 1px solid #334155; border-radius: 16px;
padding: 40px; width: 380px; color: #e2e8f0;
}
.login-header {
display: flex; align-items: center; gap: 12px; margin-bottom: 8px;
}
.login-header h1 {
font-size: 24px; color: #6366f1; margin: 0;
}
.login-sub {
color: #94a3b8; font-size: 13px; margin-bottom: 24px;
}
.login-error {
background: rgba(239,68,68,.15); color: #f87171; padding: 8px 12px;
border-radius: 8px; font-size: 13px; margin-bottom: 16px;
}
.login-card label {
display: block; font-size: 12px; color: #94a3b8; margin-bottom: 4px; text-transform: uppercase;
}
.login-card input {
width: 100%; padding: 10px 12px; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #e2e8f0; font-size: 14px; margin-bottom: 16px; outline: none;
}
.login-card input:focus { border-color: #6366f1; }
.login-card button {
width: 100%; padding: 12px; background: #6366f1; border: none; border-radius: 8px;
color: #fff; font-size: 14px; font-weight: 600; cursor: pointer;
}
.login-card button:hover { background: #4f46e5; }
.login-card button:disabled { opacity: .5; cursor: not-allowed; }
.login-erp {
color: #475569; font-size: 11px; text-align: center; margin-top: 16px;
}
.login-spinner {
width: 40px; height: 40px; border: 4px solid #334155;
border-top-color: #6366f1; border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

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

View File

@ -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 }),
},
)

View File

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

View File

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

View File

@ -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 || [])] })

View File

@ -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()
}

View File

@ -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()
}

View File

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

View File

@ -1,5 +1,5 @@
<script setup>
import { inject } from 'vue'
import { ref, inject } from 'vue'
import { fmtDur, shortAddr, prioLabel, prioClass, prioColor, dayLoadColor, ICON } from 'src/composables/useHelpers'
const props = defineProps({
@ -16,7 +16,7 @@ const emit = defineEmits([
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
'auto-distribute', 'open-criteria',
'row-click', 'row-dblclick', 'row-dragstart',
'drop-unassign',
'drop-unassign', 'lasso-select', 'deselect-all',
])
const store = inject('store')
@ -24,6 +24,73 @@ const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const btColW = inject('btColW')
const startColResize = inject('startColResize')
// Lasso selection
const btLasso = ref(null)
const btScrollRef = ref(null)
let btLassoMoved = false
function btLassoStart (e) {
if (e.target.closest('button, input, .sb-bt-checkbox, a, .sb-col-resize, .sb-bottom-hdr, .sb-bottom-resize')) return
if (e.button !== 0) return
const scroll = btScrollRef.value
if (!scroll) return
// On a job row don't start lasso, let drag handle it
const row = e.target.closest('.sb-bottom-row')
if (row) return
e.preventDefault()
btLassoMoved = false
const rect = scroll.getBoundingClientRect()
const x = e.clientX - rect.left + scroll.scrollLeft
const y = e.clientY - rect.top + scroll.scrollTop
btLasso.value = { x1: x, y1: y, x2: x, y2: y }
document.addEventListener('mousemove', btLassoMove)
document.addEventListener('mouseup', btLassoEnd)
}
function btLassoMove (e) {
if (!btLasso.value) return
e.preventDefault()
btLassoMoved = true
const scroll = btScrollRef.value
const rect = scroll.getBoundingClientRect()
btLasso.value.x2 = e.clientX - rect.left + scroll.scrollLeft
btLasso.value.y2 = e.clientY - rect.top + scroll.scrollTop
// Live selection as lasso moves
const l = btLasso.value
const h = Math.abs(l.y2 - l.y1)
if (h > 8) {
const scrollRect = scroll.getBoundingClientRect()
const lassoTop = Math.min(l.y1, l.y2) - scroll.scrollTop + scrollRect.top
const lassoBottom = lassoTop + h
const rows = scroll.querySelectorAll('.sb-bottom-row')
const ids = []
rows.forEach(row => {
const r = row.getBoundingClientRect()
if (r.bottom > lassoTop && r.top < lassoBottom) {
const jobId = row.dataset?.jobId
if (jobId) ids.push(jobId)
}
})
if (ids.length) emit('lasso-select', ids)
}
}
function btLassoEnd () {
document.removeEventListener('mousemove', btLassoMove)
document.removeEventListener('mouseup', btLassoEnd)
if (!btLasso.value) return
// If no movement = click on empty space = clear selection
if (!btLassoMoved) {
emit('deselect-all')
}
btLasso.value = null
}
</script>
<template>
@ -70,7 +137,7 @@ const startColResize = inject('startColResize')
</tr>
</thead>
</table>
<div class="sb-bottom-scroll">
<div class="sb-bottom-scroll" ref="btScrollRef" @mousedown="btLassoStart" style="position:relative">
<template v-for="group in groups" :key="group.date||'nodate'">
<div class="sb-bottom-date-sep">
<span class="sb-bottom-date-label">{{ group.label }}</span>
@ -80,8 +147,9 @@ const startColResize = inject('startColResize')
<tbody>
<tr v-for="job in group.jobs" :key="job.id"
class="sb-bottom-row" :class="{ 'sb-bottom-row-sel': selected.has(job.id) }"
:data-job-id="job.id"
draggable="true"
@dragstart="emit('row-dragstart', $event, job)"
@dragstart="emit('row-dragstart', $event, job, selected.has(job.id) && selected.size > 1)"
@click="emit('row-click', job, $event)"
@dblclick.stop="emit('row-dblclick', job)">
<td class="sb-bt-chk" style="width:28px" @click.stop="emit('toggle-select', job.id, $event)">
@ -114,6 +182,12 @@ const startColResize = inject('startColResize')
</table>
</template>
<div v-if="!unscheduledCount" class="sbf-empty" style="padding:1rem;text-align:center">Aucune job non assignée</div>
<div v-if="btLasso" class="sb-bt-lasso" :style="{
left: Math.min(btLasso.x1, btLasso.x2) + 'px',
top: Math.min(btLasso.y1, btLasso.y2) + 'px',
width: Math.abs(btLasso.x2 - btLasso.x1) + 'px',
height: Math.abs(btLasso.y2 - btLasso.y1) + 'px'
}"></div>
</div>
</div>
</div>

View File

@ -1,7 +1,8 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
import { useDispatchStore } from 'src/stores/dispatch'
import { TECH_COLORS, MAPBOX_TOKEN } from 'src/config/erpnext'
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, updateTech, createTag as apiCreateTag } from 'src/api/dispatch'
@ -31,6 +32,8 @@ import { useAutoDispatch } from 'src/composables/useAutoDispatch'
// Store
const store = useDispatchStore()
const auth = useAuthStore()
const erpUrl = BASE_URL || window.location.origin
// Date / View
const currentView = ref(localStorage.getItem('sbv2-view') || 'week')
@ -331,14 +334,14 @@ function selectJob (job, techId, isAssist = false, assistTechId = null, event =
// Drag & Drop composable
const {
dragJob, dragSrc, dragIsAssist, dropGhost,
dragJob, dragSrc, dragIsAssist, dragBatchIds, dropGhost,
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
onTechDragStart,
onTimelineDrop, onCalDrop,
startBlockMove, startResize,
} = useDragDrop({
store, pxPerHr, dayW, periodStart, periodDays, H_START,
getJobDate, bottomSelected,
getJobDate, bottomSelected, multiSelect,
pushUndo, smartAssign, invalidateRoutes,
})
@ -460,6 +463,74 @@ const bookingOverlay = ref(null)
// WO creation modal
const woModal = ref(null)
const gpsSettingsOpen = ref(false)
async function saveTraccarLink (tech, deviceId) {
tech.traccarDeviceId = deviceId || null
tech.gpsCoords = null
tech.gpsOnline = false
await updateTech(tech.name || tech.id, { traccar_device_id: deviceId || '' })
await store.pollGps()
invalidateRoutes()
}
// Tech management (GPS modal)
const editingTech = ref(null) // tech.id being edited inline
const newTechName = ref('')
const newTechPhone = ref('')
const newTechDevice = ref('')
const addingTech = ref(false)
async function saveTechField (tech, field, value) {
const trimmed = typeof value === 'string' ? value.trim() : value
if (field === 'full_name') {
if (!trimmed || trimmed === tech.fullName) return
tech.fullName = trimmed
} else if (field === 'status') {
tech.status = trimmed
} else if (field === 'phone') {
if (trimmed === tech.phone) return
tech.phone = trimmed
}
try { await updateTech(tech.name || tech.id, { [field]: trimmed }) }
catch (e) { console.error('Erreur sauvegarde tech:', e) }
}
async function addTech () {
const name = newTechName.value.trim()
if (!name || addingTech.value) return
addingTech.value = true
try {
const tech = await store.createTechnician({
full_name: name,
phone: newTechPhone.value.trim() || '',
traccar_device_id: newTechDevice.value || '',
})
newTechName.value = ''
newTechPhone.value = ''
newTechDevice.value = ''
if (tech.traccarDeviceId) await store.pollGps()
} catch (e) {
const msg = e?.message || String(e)
alert('Erreur création technicien:\n' + msg.replace(/<[^>]+>/g, ''))
}
finally { addingTech.value = false }
}
async function removeTech (tech) {
if (!confirm(`Supprimer ${tech.fullName} ?`)) return
try {
// First unassign all jobs linked to this tech
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) {
const msg = e?.message || String(e)
alert('Erreur suppression:\n' + msg.replace(/<[^>]+>/g, ''))
}
}
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 }
}
@ -504,6 +575,22 @@ function onDropUnassign (e) {
unassignDropActive.value = false
}
// Click empty space = deselect all
let _lassoJustEnded = false
function onRootClick (e) {
// Skip if a lasso just finished (mouseup click fires immediately after)
if (_lassoJustEnded) { _lassoJustEnded = false; return }
// Only if clicking on empty space not on blocks, rows, buttons, inputs, panels
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-sidebar-strip, .sb-sidebar-flyout, .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
}
}
// Keyboard
function onKeyDown (e) {
if (e.key === 'Escape') {
@ -520,7 +607,7 @@ function onKeyDown (e) {
if ((e.key === 'Delete' || e.key === 'Backspace') && (selectedJob.value || multiSelect.value.length)) {
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) return
e.preventDefault()
if (multiSelect.value.length) { batchUnassign(); return }
if (multiSelect.value.length) { batchUnassign(pushUndo); return }
const { job, isAssist, assistTechId } = selectedJob.value
if (isAssist && assistTechId) {
const assist = job.assistants.find(a => a.techId === assistTechId)
@ -565,6 +652,7 @@ provide('addrResults', addrResults)
provide('selectAddr', selectAddr)
// Lifecycle
// Auth is handled by App.vue this component only mounts when auth.user is set
onMounted(async () => {
if (!store.technicians.length) await store.loadAll()
const savedCoords = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
@ -579,17 +667,19 @@ onMounted(async () => {
const l = document.createElement('link'); l.id='mapbox-css'; l.rel='stylesheet'
l.href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l)
}
// Start GPS tracking (WebSocket + initial REST)
store.startGpsTracking()
})
onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('click', closeCtxMenu); destroyMap() })
onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('click', closeCtxMenu); destroyMap(); store.stopGpsTracking() })
</script>
<template>
<div class="sb-root">
<div class="sb-root" @click="onRootClick">
<!-- HEADER -->
<header class="sb-header">
<div class="sb-header-left">
<a class="sb-logo-btn" href="/desk/dispatch-job" title="Dispatch Jobs — ERPNext">Dispatch</a>
<a class="sb-logo-btn" :href="erpUrl + '/desk/dispatch-job'" target="_blank" title="Dispatch Jobs — ERPNext">Dispatch</a>
<input class="sb-search" v-model="searchQuery" placeholder="🔍 Ressource…" />
<div class="sb-tabs">
<button v-for="tab in boardTabs" :key="tab" class="sb-tab" :class="{ active: activeTab===tab }" @click="activeTab=tab">{{ tab }}</button>
@ -613,8 +703,14 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
</button>
<button v-if="currentView==='day'" class="sb-icon-btn" :class="{ active: mapVisible }" @click="mapVisible=!mapVisible" title="Carte">🗺 Carte</button>
<button class="sb-icon-btn" @click="refreshData()" title="Actualiser"></button>
<button class="sb-icon-btn" @click="gpsSettingsOpen=true" title="GPS Tracking">📡</button>
<button class="sb-wo-btn" @click="openWoModal()" title="Nouveau work order">+ WO</button>
<div class="sb-erp-dot" :class="{ ok: store.erpStatus==='ok' }" :title="{ ok:'ERPNext ✓', error:'Hors ligne', loading:'Connexion…' }[store.erpStatus]||'ERPNext'"></div>
<div class="sb-user-menu">
<span class="sb-user-name" v-if="auth.user">{{ auth.user }}</span>
<a v-if="auth.user" class="sb-erp-link" :href="erpUrl + '/desk'" target="_blank" title="Ouvrir ERPNext">ERP</a>
<button v-if="auth.user" class="sb-icon-btn sb-logout-btn" @click="auth.doLogout()" title="Déconnexion"></button>
<div class="sb-erp-dot" :class="{ ok: store.erpStatus==='ok' }" :title="{ ok:'ERPNext ✓', error:'Hors ligne', loading:'Connexion…' }[store.erpStatus]||'ERPNext'"></div>
</div>
</div>
</header>
@ -731,7 +827,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<!-- Center column -->
<div class="sb-center-col">
<div class="sb-board" ref="boardScroll" @mousedown="startLasso($event)" @mousemove="moveLasso($event)" @mouseup="endLasso($event)">
<div class="sb-board" ref="boardScroll" @mousedown="startLasso($event)" @mousemove="moveLasso($event)" @mouseup="if(lasso) _lassoJustEnded = true; endLasso($event)">
<div v-if="lasso" class="sb-lasso" :style="lassoStyle"></div>
<!-- Week view -->
@ -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
<transition name="sb-slide-up">
<div v-if="multiSelect.length" class="sb-multi-bar">
<span class="sb-multi-count">{{ multiSelect.length }} sélectionné{{ multiSelect.length>1?'s':'' }}</span>
<button class="sb-multi-btn" @click="batchUnassign()"> Désaffecter</button>
<button class="sb-multi-btn" @click="batchUnassign(pushUndo)"> Désaffecter</button>
<span class="sb-multi-sep">|</span>
<span class="sb-multi-lbl">Déplacer vers :</span>
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech" :style="'border-color:'+TECH_COLORS[t.colorIdx]" @click="batchMoveTo(t.id)">
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech" :style="'border-color:'+TECH_COLORS[t.colorIdx]" @click="batchMoveTo(t.id, localDateStr(periodStart), pushUndo)">
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase() }}
</button>
<button class="sb-multi-btn sb-multi-clear" @click="multiSelect=[]; selectedJob=null">Annuler</button>
@ -987,6 +1085,91 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
</div>
</div>
<!-- GPS Settings Modal -->
<div v-if="gpsSettingsOpen" class="sb-modal-overlay" @click.self="gpsSettingsOpen=false">
<div class="sb-gps-modal">
<div class="sb-gps-modal-hdr">
<h3>📡 GPS Tracking Traccar</h3>
<button class="sb-gps-close" @click="gpsSettingsOpen=false">&times;</button>
</div>
<div class="sb-gps-modal-body">
<p class="sb-gps-desc">Gérer les techniciens, associer les devices Traccar et suivre le GPS en temps réel.</p>
<table class="sb-gps-table">
<thead>
<tr>
<th>Technicien</th>
<th>Téléphone</th>
<th>Statut</th>
<th>Device GPS</th>
<th>GPS</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="tech in store.technicians" :key="tech.id">
<td>
<input v-if="editingTech === tech.id" class="sb-gps-input sb-gps-edit-name" :value="tech.fullName"
@blur="saveTechField(tech, 'full_name', $event.target.value); editingTech = null"
@keydown.enter="$event.target.blur()" @keydown.escape="editingTech = null" />
<strong v-else @dblclick="editingTech = tech.id" class="sb-gps-editable" title="Double-clic pour modifier">{{ tech.fullName }}</strong>
</td>
<td>
<input class="sb-gps-input sb-gps-phone" :value="tech.phone" placeholder="514..."
@blur="saveTechField(tech, 'phone', $event.target.value)"
@keydown.enter="$event.target.blur()" />
</td>
<td>
<select :value="tech.status || 'available'" @change="saveTechField(tech, 'status', $event.target.value)" class="sb-gps-select sb-gps-status-sel">
<option value="available">Disponible</option>
<option value="busy">Occupé</option>
<option value="off">Absent</option>
</select>
</td>
<td>
<select :value="tech.traccarDeviceId || ''" @change="saveTraccarLink(tech, $event.target.value)" class="sb-gps-select">
<option value=""> Non lié </option>
<option v-for="d in store.traccarDevices" :key="d.id" :value="String(d.id)">
{{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }}
</option>
</select>
</td>
<td>
<span v-if="tech.gpsCoords" class="sb-gps-badge sb-gps-online" :title="tech.gpsCoords[1].toFixed(4)+', '+tech.gpsCoords[0].toFixed(4)">
En ligne<span v-if="tech.gpsSpeed > 1"> · {{ (tech.gpsSpeed * 1.852).toFixed(0) }}km/h</span>
</span>
<span v-else-if="tech.traccarDeviceId" class="sb-gps-badge sb-gps-offline">Hors ligne</span>
<span v-else class="sb-gps-badge sb-gps-none"></span>
</td>
<td><button class="sb-gps-del-btn" @click="removeTech(tech)" title="Supprimer">&times;</button></td>
</tr>
<!-- Add technician row -->
<tr class="sb-gps-add-row">
<td><input v-model="newTechName" class="sb-gps-input" placeholder="Nom complet" @keydown.enter="addTech" /></td>
<td><input v-model="newTechPhone" class="sb-gps-input sb-gps-phone" placeholder="514..." /></td>
<td></td>
<td>
<select v-model="newTechDevice" class="sb-gps-select">
<option value=""> Aucun </option>
<option v-for="d in store.traccarDevices" :key="d.id" :value="String(d.id)">
{{ d.name }} {{ d.status === 'online' ? '🟢' : '⚫' }}
</option>
</select>
</td>
<td colspan="2">
<button class="sb-gps-add-btn" @click="addTech" :disabled="!newTechName.trim() || addingTech">
{{ addingTech ? '...' : '+ Ajouter' }}
</button>
</td>
</tr>
</tbody>
</table>
<div class="sb-gps-footer">
<span class="sb-gps-info">{{ store.traccarDevices.length }} devices Traccar · {{ store.technicians.length }} techniciens</span>
</div>
</div>
</div>
</div>
</div>
</template>
@ -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; }
</style>

View File

@ -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 () {

View File

@ -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) {

View File

@ -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,
}
})