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
|
// Base path = where the app is deployed under ERPNext
|
||||||
// Change this if you move the app to a different path
|
// Change this if you move the app to a different path
|
||||||
extendViteConf (viteConf) {
|
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>
|
<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>
|
</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 ──────────────────────────────────────────────
|
// ── ERPNext auth — token-based for cross-domain, cookie fallback for same-origin
|
||||||
// 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.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
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 () {
|
function getStoredToken () {
|
||||||
if (_csrf) return _csrf
|
const t = localStorage.getItem(TOKEN_KEY)
|
||||||
try {
|
if (t) return t
|
||||||
const res = await fetch(BASE_URL + '/', { credentials: 'include' })
|
// Check if we have a session but no token — prompt re-login
|
||||||
const html = await res.text()
|
return null
|
||||||
const m = html.match(/csrf_token\s*[:=]\s*['"]([^'"]+)['"]/)
|
}
|
||||||
if (m) _csrf = m[1]
|
function getStoredUser () { return localStorage.getItem(USER_KEY) }
|
||||||
} catch { /* ignore */ }
|
|
||||||
return _csrf
|
// 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) {
|
export async function login (usr, pwd) {
|
||||||
|
// 1. Try login to get session + generate API keys
|
||||||
const res = await fetch(BASE_URL + '/api/method/login', {
|
const res = await fetch(BASE_URL + '/api/method/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|
@ -31,29 +46,83 @@ export async function login (usr, pwd) {
|
||||||
if (!res.ok || data.exc_type === 'AuthenticationError') {
|
if (!res.ok || data.exc_type === 'AuthenticationError') {
|
||||||
throw new Error(data.message || 'Identifiants incorrects')
|
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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout () {
|
export async function logout () {
|
||||||
try {
|
try {
|
||||||
await fetch(BASE_URL + '/api/method/frappe.auth.logout', {
|
await authFetch(BASE_URL + '/api/method/frappe.auth.logout', { method: 'POST' })
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
} catch { /* ignore */ }
|
} 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 () {
|
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 {
|
try {
|
||||||
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
|
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const user = data.message
|
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 {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,25 @@
|
||||||
// Swap BASE_URL in config/erpnext.js to change the target server.
|
// Swap BASE_URL in config/erpnext.js to change the target server.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
import { getCSRF } from './auth'
|
import { authFetch } from './auth'
|
||||||
|
|
||||||
async function apiGet (path) {
|
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()
|
const data = await res.json()
|
||||||
if (data.exc) throw new Error(data.exc)
|
if (data.exc) throw new Error(data.exc)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPut (doctype, name, body) {
|
async function apiPut (doctype, name, body) {
|
||||||
const token = await getCSRF()
|
const res = await authFetch(
|
||||||
const res = await fetch(
|
|
||||||
`${BASE_URL}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
|
`${BASE_URL}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
credentials: 'include',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Frappe-CSRF-Token': token,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
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()
|
const data = await res.json()
|
||||||
if (data.exc) throw new Error(data.exc)
|
if (data.exc) throw new Error(data.exc)
|
||||||
return data
|
return data
|
||||||
|
|
@ -61,13 +57,11 @@ export async function updateJob (name, payload) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createJob (payload) {
|
export async function createJob (payload) {
|
||||||
const token = await getCSRF()
|
const res = await authFetch(
|
||||||
const res = await fetch(
|
|
||||||
`${BASE_URL}/api/resource/Dispatch%20Job`,
|
`${BASE_URL}/api/resource/Dispatch%20Job`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': token },
|
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -80,19 +74,39 @@ export async function updateTech (name, payload) {
|
||||||
return apiPut('Dispatch Technician', 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 () {
|
export async function fetchTags () {
|
||||||
const data = await apiGet('/api/resource/Dispatch%20Tag?fields=["name","label","color","category"]&limit=200')
|
const data = await apiGet('/api/resource/Dispatch%20Tag?fields=["name","label","color","category"]&limit=200')
|
||||||
return data.data || []
|
return data.data || []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTag (label, category = 'Custom', color = '#6b7280') {
|
export async function createTag (label, category = 'Custom', color = '#6b7280') {
|
||||||
const token = await getCSRF()
|
const res = await authFetch(
|
||||||
const res = await fetch(
|
|
||||||
`${BASE_URL}/api/resource/Dispatch%20Tag`,
|
`${BASE_URL}/api/resource/Dispatch%20Tag`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': token },
|
|
||||||
body: JSON.stringify({ label, category, color }),
|
body: JSON.stringify({ label, category, color }),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,16 @@ export async function fetchDevices () {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Positions ────────────────────────────────────────────────────────────────
|
// ── Positions ────────────────────────────────────────────────────────────────
|
||||||
|
// Traccar API only supports ONE deviceId per request — fetch in parallel
|
||||||
export async function fetchPositions (deviceIds = null) {
|
export async function fetchPositions (deviceIds = null) {
|
||||||
let url = TRACCAR_URL + '/api/positions'
|
if (!deviceIds || !deviceIds.length) return []
|
||||||
if (deviceIds && deviceIds.length) {
|
const results = await Promise.allSettled(
|
||||||
url += '?' + deviceIds.map(id => 'deviceId=' + id).join('&')
|
deviceIds.map(id =>
|
||||||
}
|
fetch(TRACCAR_URL + '/api/positions?deviceId=' + id, authOpts())
|
||||||
try {
|
.then(r => r.ok ? r.json() : [])
|
||||||
const res = await fetch(url, authOpts())
|
)
|
||||||
if (res.ok) return await res.json()
|
)
|
||||||
} catch {}
|
return results.flatMap(r => r.status === 'fulfilled' ? r.value : [])
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Get position for a specific device ───────────────────────────────────────
|
// ── Get position for a specific device ───────────────────────────────────────
|
||||||
|
|
@ -78,4 +78,17 @@ export function matchDeviceToTech (devices, techs) {
|
||||||
return matched
|
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 }
|
export { TRACCAR_URL, _devices as cachedDevices }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ export function useAutoDispatch (deps) {
|
||||||
} else {
|
} else {
|
||||||
pool = unscheduledJobs.value.filter(j => !j.scheduledDate || j.scheduledDate === today)
|
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
|
if (!unassigned.length) return
|
||||||
|
|
||||||
const prevQueues = {}
|
const prevQueues = {}
|
||||||
|
|
@ -58,7 +62,7 @@ export function useAutoDispatch (deps) {
|
||||||
techs.forEach(tech => {
|
techs.forEach(tech => {
|
||||||
let score = 0
|
let score = 0
|
||||||
if (weights.balance) score += techLoadForDay(tech, assignDay) * (weights.balance || 1)
|
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) {
|
if (weights.skills && useSkills) {
|
||||||
const jt = job.tags || [], tt = tech.tags || []
|
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)
|
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) {
|
export function useDragDrop (deps) {
|
||||||
const {
|
const {
|
||||||
store, pxPerHr, dayW, periodStart, periodDays, H_START,
|
store, pxPerHr, dayW, periodStart, periodDays, H_START,
|
||||||
getJobDate, bottomSelected,
|
getJobDate, bottomSelected, multiSelect,
|
||||||
pushUndo, smartAssign, invalidateRoutes,
|
pushUndo, smartAssign, invalidateRoutes,
|
||||||
} = deps
|
} = deps
|
||||||
|
|
||||||
|
|
@ -70,15 +70,28 @@ export function useDragDrop (deps) {
|
||||||
dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return
|
dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return
|
||||||
}
|
}
|
||||||
if (dragBatchIds.value && dragBatchIds.value.size > 1) {
|
if (dragBatchIds.value && dragBatchIds.value.size > 1) {
|
||||||
|
const prevStates = []
|
||||||
dragBatchIds.value.forEach(jobId => {
|
dragBatchIds.value.forEach(jobId => {
|
||||||
const j = store.jobs.find(x => x.id === jobId)
|
const j = store.jobs.find(x => x.id === jobId)
|
||||||
if (j && !j.assignedTech) {
|
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)
|
smartAssign(j, tech.id, dateStr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, targetTechId: tech.id })
|
||||||
bottomSelected.value = new Set()
|
bottomSelected.value = new Set()
|
||||||
dragBatchIds.value = null
|
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 {
|
} else {
|
||||||
const job = dragJob.value
|
const job = dragJob.value
|
||||||
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
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)
|
return multiSelect.value.some(s => s.job.id === jobId && s.isAssist === isAssist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Batch ops ─────────────────────────────────────────────────────────────────
|
// ── Batch ops (grouped undo) ──────────────────────────────────────────────────
|
||||||
function batchUnassign () {
|
function batchUnassign (pushUndo) {
|
||||||
if (!multiSelect.value.length) return
|
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 => {
|
multiSelect.value.forEach(s => {
|
||||||
if (s.isAssist && s.assistTechId) store.removeAssistant(s.job.id, s.assistTechId)
|
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
|
multiSelect.value = []; selectedJob.value = null
|
||||||
invalidateRoutes()
|
invalidateRoutes()
|
||||||
}
|
}
|
||||||
|
|
||||||
function batchMoveTo (techId) {
|
function batchMoveTo (techId, dayStr, pushUndo) {
|
||||||
if (!multiSelect.value.length) return
|
if (!multiSelect.value.length) return
|
||||||
const dayStr = localDateStr(periodStart.value)
|
const day = dayStr || localDateStr(periodStart.value)
|
||||||
multiSelect.value.filter(s => !s.isAssist).forEach(s => smartAssign(s.job, techId, dayStr))
|
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
|
multiSelect.value = []; selectedJob.value = null
|
||||||
invalidateRoutes()
|
invalidateRoutes()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// ── Undo stack composable ────────────────────────────────────────────────────
|
// ── Undo stack composable ────────────────────────────────────────────────────
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick } from 'vue'
|
||||||
import { updateJob } from 'src/api/dispatch'
|
import { updateJob } from 'src/api/dispatch'
|
||||||
|
import { serializeAssistants } from './useHelpers'
|
||||||
|
|
||||||
export function useUndo (store, invalidateRoutes) {
|
export function useUndo (store, invalidateRoutes) {
|
||||||
const undoStack = ref([])
|
const undoStack = ref([])
|
||||||
|
|
@ -10,6 +11,28 @@ export function useUndo (store, invalidateRoutes) {
|
||||||
if (undoStack.value.length > 30) undoStack.value.shift()
|
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 () {
|
function performUndo () {
|
||||||
const action = undoStack.value.pop()
|
const action = undoStack.value.pop()
|
||||||
if (!action) return
|
if (!action) return
|
||||||
|
|
@ -20,46 +43,34 @@ export function useUndo (store, invalidateRoutes) {
|
||||||
const job = store.jobs.find(j => j.id === action.jobId)
|
const job = store.jobs.find(j => j.id === action.jobId)
|
||||||
const a = job?.assistants.find(x => x.techId === action.techId)
|
const a = job?.assistants.find(x => x.techId === action.techId)
|
||||||
if (a) { a.duration = action.duration; a.note = action.note }
|
if (a) { a.duration = action.duration; a.note = action.note }
|
||||||
updateJob(job.name || job.id, {
|
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
|
||||||
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(() => {})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
} else if (action.type === 'optimizeRoute') {
|
} else if (action.type === 'optimizeRoute') {
|
||||||
const tech = store.technicians.find(t => t.id === action.techId)
|
const tech = store.technicians.find(t => t.id === action.techId)
|
||||||
if (tech) {
|
if (tech) {
|
||||||
tech.queue = action.prevQueue
|
tech.queue = action.prevQueue
|
||||||
action.prevQueue.forEach((j, i) => { j.routeOrder = i })
|
action.prevQueue.forEach((j, i) => { j.routeOrder = i })
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (action.type === 'autoDistribute') {
|
} else if (action.type === 'autoDistribute') {
|
||||||
// Unassign all the jobs that were auto-assigned
|
action.assignments.forEach(a => _restoreJob(a))
|
||||||
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
|
|
||||||
if (action.prevQueues) {
|
if (action.prevQueues) {
|
||||||
store.technicians.forEach(t => {
|
store.technicians.forEach(t => {
|
||||||
if (action.prevQueues[t.id]) t.queue = action.prevQueues[t.id]
|
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') {
|
} else if (action.type === 'unassignJob') {
|
||||||
store.assignJobToTech(action.jobId, action.techId, action.routeOrder, action.scheduledDate)
|
_restoreJob(action)
|
||||||
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(() => {})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild assistJobs on all techs
|
||||||
|
store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) })
|
||||||
invalidateRoutes()
|
invalidateRoutes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
// - Update api/auth.js if switching from session cookie to JWT
|
// - Update api/auth.js if switching from session cookie to JWT
|
||||||
// For same-origin (ERPNext serves the app): keep BASE_URL as empty string.
|
// 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)
|
// Mapbox public token — safe to expose (scope-limited in Mapbox dashboard)
|
||||||
export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
|
export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import { fmtDur, shortAddr, prioLabel, prioClass, prioColor, dayLoadColor, ICON } from 'src/composables/useHelpers'
|
import { fmtDur, shortAddr, prioLabel, prioClass, prioColor, dayLoadColor, ICON } from 'src/composables/useHelpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -16,7 +16,7 @@ const emit = defineEmits([
|
||||||
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
|
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
|
||||||
'auto-distribute', 'open-criteria',
|
'auto-distribute', 'open-criteria',
|
||||||
'row-click', 'row-dblclick', 'row-dragstart',
|
'row-click', 'row-dblclick', 'row-dragstart',
|
||||||
'drop-unassign',
|
'drop-unassign', 'lasso-select', 'deselect-all',
|
||||||
])
|
])
|
||||||
|
|
||||||
const store = inject('store')
|
const store = inject('store')
|
||||||
|
|
@ -24,6 +24,73 @@ const TECH_COLORS = inject('TECH_COLORS')
|
||||||
const jobColor = inject('jobColor')
|
const jobColor = inject('jobColor')
|
||||||
const btColW = inject('btColW')
|
const btColW = inject('btColW')
|
||||||
const startColResize = inject('startColResize')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -70,7 +137,7 @@ const startColResize = inject('startColResize')
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
</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'">
|
<template v-for="group in groups" :key="group.date||'nodate'">
|
||||||
<div class="sb-bottom-date-sep">
|
<div class="sb-bottom-date-sep">
|
||||||
<span class="sb-bottom-date-label">{{ group.label }}</span>
|
<span class="sb-bottom-date-label">{{ group.label }}</span>
|
||||||
|
|
@ -80,8 +147,9 @@ const startColResize = inject('startColResize')
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="job in group.jobs" :key="job.id"
|
<tr v-for="job in group.jobs" :key="job.id"
|
||||||
class="sb-bottom-row" :class="{ 'sb-bottom-row-sel': selected.has(job.id) }"
|
class="sb-bottom-row" :class="{ 'sb-bottom-row-sel': selected.has(job.id) }"
|
||||||
|
:data-job-id="job.id"
|
||||||
draggable="true"
|
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)"
|
@click="emit('row-click', job, $event)"
|
||||||
@dblclick.stop="emit('row-dblclick', job)">
|
@dblclick.stop="emit('row-dblclick', job)">
|
||||||
<td class="sb-bt-chk" style="width:28px" @click.stop="emit('toggle-select', job.id, $event)">
|
<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>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!unscheduledCount" class="sbf-empty" style="padding:1rem;text-align:center">Aucune job non assignée</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from 'vue'
|
||||||
import { useDispatchStore } from 'src/stores/dispatch'
|
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 { fetchOpenRequests } from 'src/api/service-request'
|
||||||
import { updateJob, updateTech, createTag as apiCreateTag } from 'src/api/dispatch'
|
import { updateJob, updateTech, createTag as apiCreateTag } from 'src/api/dispatch'
|
||||||
|
|
||||||
|
|
@ -31,6 +32,8 @@ import { useAutoDispatch } from 'src/composables/useAutoDispatch'
|
||||||
|
|
||||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||||
const store = useDispatchStore()
|
const store = useDispatchStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const erpUrl = BASE_URL || window.location.origin
|
||||||
|
|
||||||
// ─── Date / View ─────────────────────────────────────────────────────────────
|
// ─── Date / View ─────────────────────────────────────────────────────────────
|
||||||
const currentView = ref(localStorage.getItem('sbv2-view') || 'week')
|
const currentView = ref(localStorage.getItem('sbv2-view') || 'week')
|
||||||
|
|
@ -331,14 +334,14 @@ function selectJob (job, techId, isAssist = false, assistTechId = null, event =
|
||||||
|
|
||||||
// ─── Drag & Drop composable ──────────────────────────────────────────────────
|
// ─── Drag & Drop composable ──────────────────────────────────────────────────
|
||||||
const {
|
const {
|
||||||
dragJob, dragSrc, dragIsAssist, dropGhost,
|
dragJob, dragSrc, dragIsAssist, dragBatchIds, dropGhost,
|
||||||
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
|
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
|
||||||
onTechDragStart,
|
onTechDragStart,
|
||||||
onTimelineDrop, onCalDrop,
|
onTimelineDrop, onCalDrop,
|
||||||
startBlockMove, startResize,
|
startBlockMove, startResize,
|
||||||
} = useDragDrop({
|
} = useDragDrop({
|
||||||
store, pxPerHr, dayW, periodStart, periodDays, H_START,
|
store, pxPerHr, dayW, periodStart, periodDays, H_START,
|
||||||
getJobDate, bottomSelected,
|
getJobDate, bottomSelected, multiSelect,
|
||||||
pushUndo, smartAssign, invalidateRoutes,
|
pushUndo, smartAssign, invalidateRoutes,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -460,6 +463,74 @@ const bookingOverlay = ref(null)
|
||||||
|
|
||||||
// ─── WO creation modal ────────────────────────────────────────────────────────
|
// ─── WO creation modal ────────────────────────────────────────────────────────
|
||||||
const woModal = ref(null)
|
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) {
|
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 }
|
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
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Keyboard ─────────────────────────────────────────────────────────────────
|
||||||
function onKeyDown (e) {
|
function onKeyDown (e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
|
@ -520,7 +607,7 @@ function onKeyDown (e) {
|
||||||
if ((e.key === 'Delete' || e.key === 'Backspace') && (selectedJob.value || multiSelect.value.length)) {
|
if ((e.key === 'Delete' || e.key === 'Backspace') && (selectedJob.value || multiSelect.value.length)) {
|
||||||
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) return
|
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (multiSelect.value.length) { batchUnassign(); return }
|
if (multiSelect.value.length) { batchUnassign(pushUndo); return }
|
||||||
const { job, isAssist, assistTechId } = selectedJob.value
|
const { job, isAssist, assistTechId } = selectedJob.value
|
||||||
if (isAssist && assistTechId) {
|
if (isAssist && assistTechId) {
|
||||||
const assist = job.assistants.find(a => a.techId === assistTechId)
|
const assist = job.assistants.find(a => a.techId === assistTechId)
|
||||||
|
|
@ -565,6 +652,7 @@ provide('addrResults', addrResults)
|
||||||
provide('selectAddr', selectAddr)
|
provide('selectAddr', selectAddr)
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
// Auth is handled by App.vue — this component only mounts when auth.user is set
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!store.technicians.length) await store.loadAll()
|
if (!store.technicians.length) await store.loadAll()
|
||||||
const savedCoords = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
|
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'
|
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)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sb-root">
|
<div class="sb-root" @click="onRootClick">
|
||||||
|
|
||||||
<!-- ══ HEADER ══ -->
|
<!-- ══ HEADER ══ -->
|
||||||
<header class="sb-header">
|
<header class="sb-header">
|
||||||
<div class="sb-header-left">
|
<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…" />
|
<input class="sb-search" v-model="searchQuery" placeholder="🔍 Ressource…" />
|
||||||
<div class="sb-tabs">
|
<div class="sb-tabs">
|
||||||
<button v-for="tab in boardTabs" :key="tab" class="sb-tab" :class="{ active: activeTab===tab }" @click="activeTab=tab">{{ tab }}</button>
|
<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>
|
||||||
<button v-if="currentView==='day'" class="sb-icon-btn" :class="{ active: mapVisible }" @click="mapVisible=!mapVisible" title="Carte">🗺 Carte</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="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>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -731,7 +827,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
||||||
|
|
||||||
<!-- Center column -->
|
<!-- Center column -->
|
||||||
<div class="sb-center-col">
|
<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>
|
<div v-if="lasso" class="sb-lasso" :style="lassoStyle"></div>
|
||||||
|
|
||||||
<!-- Week view -->
|
<!-- Week view -->
|
||||||
|
|
@ -788,9 +884,11 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
||||||
:selected="bottomSelected" :drop-active="unassignDropActive"
|
:selected="bottomSelected" :drop-active="unassignDropActive"
|
||||||
@update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize"
|
@update:open="v => bottomPanelOpen = v" @resize-start="startBottomResize"
|
||||||
@toggle-select="toggleBottomSelect" @select-all="selectAllBottom" @clear-select="clearBottomSelect"
|
@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"
|
@batch-assign="batchAssignBottom" @auto-distribute="autoDistribute"
|
||||||
@open-criteria="dispatchCriteriaModal=true"
|
@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-dblclick="openEditModal"
|
||||||
@row-dragstart="(e, job) => onJobDragStart(e, job, null)"
|
@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) }" />
|
@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">
|
<transition name="sb-slide-up">
|
||||||
<div v-if="multiSelect.length" class="sb-multi-bar">
|
<div v-if="multiSelect.length" class="sb-multi-bar">
|
||||||
<span class="sb-multi-count">{{ multiSelect.length }} sélectionné{{ multiSelect.length>1?'s':'' }}</span>
|
<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-sep">|</span>
|
||||||
<span class="sb-multi-lbl">Déplacer vers :</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() }}
|
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase() }}
|
||||||
</button>
|
</button>
|
||||||
<button class="sb-multi-btn sb-multi-clear" @click="multiSelect=[]; selectedJob=null">Annuler</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>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</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-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 { 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-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 { width:7px; height:7px; border-radius:50%; background:var(--sb-red); transition:background 0.3s; }
|
||||||
.sb-erp-dot.ok { background:var(--sb-green); }
|
.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; }
|
.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-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 { 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-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 { width:4px; height:4px; }
|
||||||
.sb-bottom-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
|
.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-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-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 { width:4px; }
|
||||||
.sb-bottom-scroll::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
|
.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; }
|
.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:hover { background:var(--sb-card-h); }
|
||||||
.sb-bottom-row-sel { background:rgba(99,102,241,0.1) !important; }
|
.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-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-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-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; }
|
.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 { 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 { 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-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; }
|
.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 ── */
|
/* ── Right panel ── */
|
||||||
|
|
@ -1340,10 +1533,12 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; }
|
||||||
|
|
||||||
/* ── Modals ── */
|
/* ── 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-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-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-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-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-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; }
|
.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-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-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; }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,9 @@ import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
// Routes — add pages here; no change needed in stores or API
|
// Routes — add pages here; no change needed in stores or API
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', component: () => import('pages/DispatchPage.vue') },
|
{ path: '/', component: () => import('pages/DispatchV2Page.vue') },
|
||||||
{ path: '/mobile', component: () => import('pages/MobilePage.vue') },
|
{ path: '/mobile', component: () => import('pages/MobilePage.vue') },
|
||||||
{ path: '/admin', component: () => import('pages/AdminPage.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 () {
|
export default route(function () {
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,16 @@ import { login, logout, getLoggedUser } from 'src/api/auth'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref(null) // email string when logged in, null when guest
|
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('')
|
const error = ref('')
|
||||||
|
|
||||||
async function checkSession () {
|
async function checkSession () {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
user.value = await getLoggedUser()
|
try {
|
||||||
loading.value = false
|
user.value = await getLoggedUser()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doLogin (usr, pwd) {
|
async function doLogin (usr, pwd) {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,16 @@
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags } from 'src/api/dispatch'
|
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch'
|
||||||
import { fetchDevices, fetchPositions } from 'src/api/traccar'
|
import { fetchDevices, fetchPositions, createTraccarSession } from 'src/api/traccar'
|
||||||
import { TECH_COLORS } from 'src/config/erpnext'
|
import { TECH_COLORS } from 'src/config/erpnext'
|
||||||
import { serializeAssistants } from 'src/composables/useHelpers'
|
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', () => {
|
export const useDispatchStore = defineStore('dispatch', () => {
|
||||||
const technicians = ref([])
|
const technicians = ref([])
|
||||||
const jobs = ref([])
|
const jobs = ref([])
|
||||||
|
|
@ -54,6 +59,8 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
||||||
gpsTime: null,
|
gpsTime: null,
|
||||||
gpsOnline: false,
|
gpsOnline: false,
|
||||||
traccarDeviceId: t.traccar_device_id || null,
|
traccarDeviceId: t.traccar_device_id || null,
|
||||||
|
phone: t.phone || '',
|
||||||
|
email: t.email || '',
|
||||||
queue: [], // filled in loadAll()
|
queue: [], // filled in loadAll()
|
||||||
tags: (t.tags || []).map(tg => tg.tag),
|
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)) })
|
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([])
|
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 () {
|
async function pollGps () {
|
||||||
|
if (__gpsPolling) return
|
||||||
|
__gpsPolling = true
|
||||||
try {
|
try {
|
||||||
if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices()
|
if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices()
|
||||||
console.log('[GPS] Devices loaded:', traccarDevices.value.length)
|
_buildTechDeviceMap()
|
||||||
// Build map of traccarDeviceId → tech
|
const deviceIds = Object.keys(_techsByDevice).map(Number)
|
||||||
const techsByDevice = {}
|
if (!deviceIds.length) return
|
||||||
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)
|
|
||||||
const positions = await fetchPositions(deviceIds)
|
const positions = await fetchPositions(deviceIds)
|
||||||
console.log('[GPS] Got', positions.length, 'positions')
|
_applyPositions(positions)
|
||||||
positions.forEach(p => {
|
Object.values(_techsByDevice).forEach(t => {
|
||||||
const tech = techsByDevice[p.deviceId]
|
if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false
|
||||||
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
|
|
||||||
})
|
})
|
||||||
} catch (e) { console.warn('[GPS] Poll error:', e.message) }
|
} catch (e) { console.warn('[GPS] Poll error:', e.message) }
|
||||||
|
finally { __gpsPolling = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function startGpsPolling (intervalMs = 30000) {
|
// WebSocket connection with auto-reconnect
|
||||||
if (_gpsInterval) return
|
let __ws = null
|
||||||
pollGps() // immediate first poll
|
let __wsBackoff = 1000
|
||||||
_gpsInterval = setInterval(pollGps, intervalMs)
|
|
||||||
|
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 () {
|
async function startGpsTracking () {
|
||||||
if (_gpsInterval) { clearInterval(_gpsInterval); _gpsInterval = null }
|
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 {
|
return {
|
||||||
|
|
@ -324,6 +411,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
||||||
loadAll, loadJobsForTech,
|
loadAll, loadJobsForTech,
|
||||||
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
|
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
|
||||||
smartAssign, fullUnassign,
|
smartAssign, fullUnassign,
|
||||||
pollGps, startGpsPolling, stopGpsPolling,
|
pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling,
|
||||||
|
createTechnician, deleteTechnician,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user