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:
parent
f1badea201
commit
af42c6082e
|
|
@ -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/'
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
87
src/App.vue
87
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
117
src/api/auth.js
117
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 || [])] })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">×</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">×</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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user