Compare commits
No commits in common. "320655b0a0a42b42748d59596a8ffd8561c12cbb" and "fa37426f34cbcfff7eeca915283d4b61e9635529" have entirely different histories.
320655b0a0
...
fa37426f34
|
|
@ -1,263 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<title>Gigafibre</title>
|
|
||||||
<link rel="icon" type="image/png" href="/icons/favicon-128x128.png">
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; color: #1e293b; -webkit-font-smoothing: antialiased; }
|
|
||||||
.chat-page { display: flex; flex-direction: column; height: 100vh; height: 100dvh; max-width: 600px; margin: 0 auto; background: #f8fafc; position: relative; }
|
|
||||||
.chat-loading, .chat-error { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; gap: 12px; }
|
|
||||||
.chat-error-icon { font-size: 48px; }
|
|
||||||
.spinner { width: 36px; height: 36px; border: 3px solid #e2e8f0; border-top-color: #4f46e5; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.chat-header { display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #fff; border-bottom: 1px solid #e2e8f0; }
|
|
||||||
.chat-logo { width: 36px; height: 36px; border-radius: 8px; background: #4f46e5; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 14px; flex-shrink: 0; }
|
|
||||||
.chat-title { font-weight: 600; font-size: 1rem; }
|
|
||||||
.chat-sub { font-size: 0.8rem; color: #94a3b8; }
|
|
||||||
|
|
||||||
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; }
|
|
||||||
|
|
||||||
.chat-msg { display: flex; }
|
|
||||||
.chat-msg-customer { justify-content: flex-end; }
|
|
||||||
.chat-msg-agent { justify-content: flex-start; }
|
|
||||||
.chat-msg-system { justify-content: center; }
|
|
||||||
|
|
||||||
.chat-bubble { max-width: 80%; padding: 10px 14px; border-radius: 16px; font-size: 0.92rem; line-height: 1.4; word-break: break-word; }
|
|
||||||
.chat-msg-customer .chat-bubble { background: #4f46e5; color: #fff; border-bottom-right-radius: 4px; }
|
|
||||||
.chat-msg-agent .chat-bubble { background: #fff; color: #1e293b; border: 1px solid #e2e8f0; border-bottom-left-radius: 4px; }
|
|
||||||
.chat-msg-system .chat-bubble { background: transparent; color: #94a3b8; font-size: 0.8rem; padding: 4px 8px; }
|
|
||||||
|
|
||||||
.chat-meta { font-size: 0.7rem; opacity: 0.6; margin-top: 4px; text-align: right; }
|
|
||||||
.chat-via { background: rgba(255,255,255,0.2); padding: 1px 4px; border-radius: 3px; font-size: 0.65rem; margin-right: 4px; }
|
|
||||||
.chat-msg-agent .chat-via { background: #f1f5f9; }
|
|
||||||
|
|
||||||
.chat-input-bar { display: flex; gap: 8px; padding: 10px 12px; background: #fff; border-top: 1px solid #e2e8f0; }
|
|
||||||
.chat-input { flex: 1; border: 1px solid #e2e8f0; border-radius: 20px; padding: 10px 16px; font-size: 0.92rem; outline: none; resize: none; font-family: inherit; min-height: 42px; max-height: 120px; }
|
|
||||||
.chat-input:focus { border-color: #4f46e5; }
|
|
||||||
.chat-send { width: 42px; height: 42px; border-radius: 50%; background: #4f46e5; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.2s; }
|
|
||||||
.chat-send:hover { background: #4338ca; }
|
|
||||||
.chat-send:disabled { background: #cbd5e1; cursor: default; }
|
|
||||||
.chat-send svg { width: 20px; height: 20px; }
|
|
||||||
|
|
||||||
.chat-closed { padding: 16px; text-align: center; color: #94a3b8; font-size: 0.9rem; border-top: 1px solid #e2e8f0; }
|
|
||||||
|
|
||||||
.chat-push-banner { position: absolute; bottom: 70px; left: 12px; right: 12px; background: #4f46e5; color: #fff; padding: 10px 14px; border-radius: 10px; display: flex; align-items: center; font-size: 0.85rem; cursor: pointer; box-shadow: 0 4px 12px rgba(79,70,229,0.3); gap: 8px; }
|
|
||||||
.chat-push-banner .close { margin-left: auto; background: none; border: none; color: #fff; cursor: pointer; font-size: 18px; padding: 0 4px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app" class="chat-page">
|
|
||||||
<div class="chat-loading">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<div>Chargement...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const HUB_URL = 'https://msg.gigafibre.ca'
|
|
||||||
const token = window.location.pathname.split('/c/')[1]?.split('/')[0]?.split('?')[0]
|
|
||||||
|
|
||||||
const app = document.getElementById('app')
|
|
||||||
let conv = null
|
|
||||||
let eventSource = null
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
showError('Lien invalide')
|
|
||||||
} else {
|
|
||||||
loadConversation()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConversation () {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${HUB_URL}/conversations/${token}`)
|
|
||||||
if (!res.ok) { showError('Conversation introuvable'); return }
|
|
||||||
conv = await res.json()
|
|
||||||
render()
|
|
||||||
connectSSE()
|
|
||||||
checkPush()
|
|
||||||
} catch {
|
|
||||||
showError('Erreur de connexion')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError (msg) {
|
|
||||||
app.innerHTML = `<div class="chat-error"><div class="chat-error-icon">⚠</div><div style="font-size:1.1rem;font-weight:600">${msg}</div><div style="font-size:0.85rem">Ce lien a peut-être expiré ou est invalide.</div></div>`
|
|
||||||
}
|
|
||||||
|
|
||||||
function render () {
|
|
||||||
const msgs = conv.messages.map(m => `
|
|
||||||
<div class="chat-msg chat-msg-${m.from}">
|
|
||||||
<div class="chat-bubble">
|
|
||||||
<div>${escapeHtml(m.text)}</div>
|
|
||||||
<div class="chat-meta">${m.via === 'sms' ? '<span class="chat-via">SMS</span>' : ''}${formatTime(m.ts)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')
|
|
||||||
|
|
||||||
const inputBar = conv.status === 'active'
|
|
||||||
? `<div class="chat-input-bar">
|
|
||||||
<textarea id="msgInput" class="chat-input" placeholder="Tapez votre message..." rows="1"></textarea>
|
|
||||||
<button id="sendBtn" class="chat-send" disabled>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>`
|
|
||||||
: '<div class="chat-closed">✓ Cette conversation est terminée.</div>'
|
|
||||||
|
|
||||||
app.innerHTML = `
|
|
||||||
<div class="chat-header">
|
|
||||||
<div class="chat-logo">G</div>
|
|
||||||
<div>
|
|
||||||
<div class="chat-title">${escapeHtml(conv.subject || 'Gigafibre')}</div>
|
|
||||||
<div class="chat-sub">${escapeHtml(conv.customerName || '')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="messages" class="chat-messages">
|
|
||||||
${msgs || '<div class="chat-empty"><div style="font-size:32px;color:#cbd5e1">💬</div><div>Commencez la conversation</div></div>'}
|
|
||||||
</div>
|
|
||||||
${inputBar}
|
|
||||||
`
|
|
||||||
|
|
||||||
scrollToBottom()
|
|
||||||
|
|
||||||
// Bind events
|
|
||||||
const input = document.getElementById('msgInput')
|
|
||||||
const btn = document.getElementById('sendBtn')
|
|
||||||
if (input && btn) {
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
btn.disabled = !input.value.trim()
|
|
||||||
input.style.height = 'auto'
|
|
||||||
input.style.height = Math.min(input.scrollHeight, 120) + 'px'
|
|
||||||
})
|
|
||||||
input.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
|
|
||||||
})
|
|
||||||
btn.addEventListener('click', sendMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage () {
|
|
||||||
const input = document.getElementById('msgInput')
|
|
||||||
const text = input?.value?.trim()
|
|
||||||
if (!text) return
|
|
||||||
|
|
||||||
input.value = ''
|
|
||||||
input.style.height = 'auto'
|
|
||||||
document.getElementById('sendBtn').disabled = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ text }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.message && !conv.messages.find(m => m.id === data.message.id)) {
|
|
||||||
conv.messages.push(data.message)
|
|
||||||
appendMessage(data.message)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Send error:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendMessage (msg) {
|
|
||||||
const container = document.getElementById('messages')
|
|
||||||
if (!container) return
|
|
||||||
// Remove empty state
|
|
||||||
const empty = container.querySelector('.chat-empty')
|
|
||||||
if (empty) empty.remove()
|
|
||||||
|
|
||||||
const div = document.createElement('div')
|
|
||||||
div.className = `chat-msg chat-msg-${msg.from}`
|
|
||||||
div.innerHTML = `
|
|
||||||
<div class="chat-bubble">
|
|
||||||
<div>${escapeHtml(msg.text)}</div>
|
|
||||||
<div class="chat-meta">${msg.via === 'sms' ? '<span class="chat-via">SMS</span>' : ''}${formatTime(msg.ts)}</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
container.appendChild(div)
|
|
||||||
scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectSSE () {
|
|
||||||
eventSource = new EventSource(`${HUB_URL}/conversations/${token}/sse`)
|
|
||||||
eventSource.addEventListener('conv-message', (e) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data)
|
|
||||||
const msg = data.message
|
|
||||||
if (!conv.messages.find(m => m.id === msg.id)) {
|
|
||||||
conv.messages.push(msg)
|
|
||||||
appendMessage(msg)
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkPush () {
|
|
||||||
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !conv.vapidPublicKey) return
|
|
||||||
if (Notification.permission === 'granted') { registerPush(); return }
|
|
||||||
if (Notification.permission === 'default') {
|
|
||||||
// Show banner after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
const banner = document.createElement('div')
|
|
||||||
banner.className = 'chat-push-banner'
|
|
||||||
banner.innerHTML = '🔔 Activer les notifications pour ne pas manquer de réponse <button class="close">✕</button>'
|
|
||||||
banner.addEventListener('click', async (e) => {
|
|
||||||
if (e.target.classList.contains('close')) { banner.remove(); return }
|
|
||||||
const perm = await Notification.requestPermission()
|
|
||||||
banner.remove()
|
|
||||||
if (perm === 'granted') registerPush()
|
|
||||||
})
|
|
||||||
app.appendChild(banner)
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerPush () {
|
|
||||||
try {
|
|
||||||
const reg = await navigator.serviceWorker.ready
|
|
||||||
const sub = await reg.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlBase64ToUint8Array(conv.vapidPublicKey),
|
|
||||||
})
|
|
||||||
await fetch(`${HUB_URL}/conversations/${token}/push`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(sub.toJSON()),
|
|
||||||
})
|
|
||||||
} catch (e) { console.error('Push registration failed:', e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlBase64ToUint8Array (base64) {
|
|
||||||
const padding = '='.repeat((4 - base64.length % 4) % 4)
|
|
||||||
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
|
|
||||||
const raw = atob(b64)
|
|
||||||
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom () {
|
|
||||||
const el = document.getElementById('messages')
|
|
||||||
if (el) el.scrollTop = el.scrollHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml (str) {
|
|
||||||
if (!str) return ''
|
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime (ts) {
|
|
||||||
if (!ts) return ''
|
|
||||||
const d = new Date(ts)
|
|
||||||
const now = new Date()
|
|
||||||
const time = d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
if (d.toDateString() === now.toDateString()) return time
|
|
||||||
return d.toLocaleDateString('fr-CA', { month: 'short', day: 'numeric' }) + ' ' + time
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -7,10 +7,5 @@ import { onMounted } from 'vue'
|
||||||
import { useCustomerStore } from 'src/stores/customer'
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
|
||||||
const store = useCustomerStore()
|
const store = useCustomerStore()
|
||||||
onMounted(() => {
|
onMounted(() => store.init())
|
||||||
store.init().catch(() => {
|
|
||||||
// Auth not available (standalone portal mode) — continue as guest
|
|
||||||
console.info('[Portal] Auth unavailable — running in guest mode')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
|
|
||||||
|
|
||||||
const get = async (path) => { const r = await fetch(HUB + path); if (!r.ok) throw new Error(`Hub ${r.status}`); return r.json() }
|
|
||||||
const post = async (path, body) => { const r = await fetch(HUB + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!r.ok) throw new Error(`Hub ${r.status}`); return r.json() }
|
|
||||||
|
|
||||||
export const fetchCatalog = () => get('/api/catalog')
|
|
||||||
export const submitOrder = (payload) => post('/api/checkout', payload)
|
|
||||||
export const getOrderStatus = (id) => get(`/api/order/${encodeURIComponent(id)}`)
|
|
||||||
export const searchAddresses = async (q, limit = 8) => (await post('/api/address-search', { q, limit })).results || []
|
|
||||||
export const sendOTP = (identifier) => post('/api/otp/send', { identifier })
|
|
||||||
export const verifyOTP = (identifier, code) => post('/api/otp/verify', { identifier, code })
|
|
||||||
|
|
@ -4,8 +4,6 @@ import { BASE_URL } from 'src/config/erpnext'
|
||||||
async function apiGet (path) {
|
async function apiGet (path) {
|
||||||
const res = await authFetch(BASE_URL + path)
|
const res = await authFetch(BASE_URL + path)
|
||||||
if (!res.ok) throw new Error(`API ${res.status}: ${path}`)
|
if (!res.ok) throw new Error(`API ${res.status}: ${path}`)
|
||||||
const ct = (res.headers.get('content-type') || '')
|
|
||||||
if (!ct.includes('application/json')) throw new Error('Not JSON response for: ' + 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
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { ref, onUnmounted } from 'vue'
|
|
||||||
import { searchAddresses } from 'src/api/catalog'
|
|
||||||
|
|
||||||
export function useAddressSearch (form) {
|
|
||||||
const query = ref('')
|
|
||||||
const results = ref([])
|
|
||||||
const searching = ref(false)
|
|
||||||
const selected = ref(false)
|
|
||||||
let debounce = null
|
|
||||||
|
|
||||||
function onInput (val) {
|
|
||||||
selected.value = false
|
|
||||||
Object.assign(form, { address: '', city: '', postalCode: '', latitude: null, longitude: null })
|
|
||||||
if (debounce) clearTimeout(debounce)
|
|
||||||
if (!val || val.length < 3) { results.value = []; return }
|
|
||||||
debounce = setTimeout(async () => {
|
|
||||||
searching.value = true
|
|
||||||
try { results.value = await searchAddresses(val) }
|
|
||||||
catch { results.value = [] }
|
|
||||||
finally { searching.value = false }
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectResult (a) {
|
|
||||||
Object.assign(form, {
|
|
||||||
address: a.adresse_formatee || '', city: a.nom_municipalite || '',
|
|
||||||
postalCode: a.code_postal || '', latitude: a.latitude || null,
|
|
||||||
longitude: a.longitude || null, province: 'QC',
|
|
||||||
})
|
|
||||||
query.value = a.adresse_formatee || ''
|
|
||||||
results.value = []
|
|
||||||
selected.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCustomerAddr (addr) {
|
|
||||||
Object.assign(form, {
|
|
||||||
address: addr.address || '', city: addr.city || '',
|
|
||||||
postalCode: addr.postal_code || '', latitude: addr.latitude || null,
|
|
||||||
longitude: addr.longitude || null, province: 'QC',
|
|
||||||
})
|
|
||||||
query.value = addr.address || ''
|
|
||||||
selected.value = true
|
|
||||||
results.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => { if (debounce) clearTimeout(debounce) })
|
|
||||||
|
|
||||||
return { query, results, searching, selected, onInput, selectResult, selectCustomerAddr }
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { ref, nextTick } from 'vue'
|
|
||||||
import { sendOTP as apiSendOTP, verifyOTP as apiVerifyOTP } from 'src/api/catalog'
|
|
||||||
import { normalizePhone } from 'src/utils/format'
|
|
||||||
|
|
||||||
export function useOTP (form, { onVerified } = {}) {
|
|
||||||
const inputRef = ref(null)
|
|
||||||
const identifier = ref('')
|
|
||||||
const step = ref('idle') // idle | verify
|
|
||||||
const code = ref('')
|
|
||||||
const sending = ref(false)
|
|
||||||
const verifying = ref(false)
|
|
||||||
const verified = ref(false)
|
|
||||||
const channel = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const addresses = ref([])
|
|
||||||
|
|
||||||
async function send () {
|
|
||||||
sending.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
const result = await apiSendOTP(identifier.value)
|
|
||||||
if (!result.found) {
|
|
||||||
error.value = 'Aucun compte trouvé avec cet identifiant.'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
step.value = 'verify'
|
|
||||||
channel.value = result.channel
|
|
||||||
nextTick(() => inputRef.value?.focus())
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message || 'Erreur'
|
|
||||||
return false
|
|
||||||
} finally { sending.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verify () {
|
|
||||||
verifying.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
const result = await apiVerifyOTP(identifier.value, code.value)
|
|
||||||
if (!result.valid) {
|
|
||||||
error.value = result.reason === 'expired' ? 'Code expiré. Renvoyez un nouveau code.'
|
|
||||||
: result.reason === 'wrong_code' ? 'Code invalide. Vérifiez et réessayez.'
|
|
||||||
: 'Vérification échouée.'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
verified.value = true
|
|
||||||
form.customer_id = result.customer_id
|
|
||||||
form.name = result.customer_name || form.name
|
|
||||||
if (result.phone) form.phone = normalizePhone(result.phone)
|
|
||||||
if (result.email) form.email = result.email
|
|
||||||
else if (identifier.value.includes('@')) form.email = identifier.value
|
|
||||||
if (result.addresses?.length) addresses.value = result.addresses
|
|
||||||
onVerified?.(result)
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message || 'Erreur'
|
|
||||||
return false
|
|
||||||
} finally { verifying.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset () {
|
|
||||||
step.value = 'idle'
|
|
||||||
code.value = ''
|
|
||||||
verified.value = false
|
|
||||||
error.value = ''
|
|
||||||
addresses.value = []
|
|
||||||
form.customer_id = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return { inputRef, identifier, step, code, sending, verifying, verified, channel, error, addresses, send, verify, reset }
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
// ISP product catalog — extracted from CatalogPage to reduce component size
|
|
||||||
export const CATALOG = [
|
|
||||||
{ item_code: 'INT-100', item_name: 'Internet 100 Mbps', rate: 49.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install', description: 'Parfait pour le web et streaming HD', speed_down: 100, speed_up: 30 },
|
|
||||||
{ item_code: 'INT-300', item_name: 'Internet 300 Mbps', rate: 69.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install', description: 'Idéal pour familles et télétravail', speed_down: 300, speed_up: 100, popular: true },
|
|
||||||
{ item_code: 'INT-500', item_name: 'Internet 500 Mbps', rate: 89.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install', description: 'Pour les gros téléchargements et le gaming', speed_down: 500, speed_up: 200 },
|
|
||||||
{ item_code: 'INT-1000', item_name: 'Internet 1 Gbps', rate: 109.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install', description: 'La vitesse maximale pour les professionnels', speed_down: 1000, speed_up: 500 },
|
|
||||||
{ item_code: 'TEL-BASE', item_name: 'Téléphonie résidentielle', rate: 19.99, billing_type: 'Mensuel', service_category: 'Téléphonie', requires_visit: false, description: 'Appels illimités au Canada + afficheur' },
|
|
||||||
{ item_code: 'TEL-INTL', item_name: 'Téléphonie internationale', rate: 29.99, billing_type: 'Mensuel', service_category: 'Téléphonie', requires_visit: false, description: 'Appels illimités Canada + 60 destinations internationales' },
|
|
||||||
{ item_code: 'BDL-300-TEL', item_name: 'Bundle Internet 300 + Téléphonie', rate: 79.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', description: 'Internet rapide et téléphonie à prix réduit', speed_down: 300, speed_up: 100, bundle_includes: ['Internet 300 Mbps', 'Téléphonie résidentielle'] },
|
|
||||||
{ item_code: 'BDL-500-TEL-IPTV', item_name: 'Bundle Internet 500 + Téléphonie + IPTV', rate: 109.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', description: 'Le forfait complet pour toute la famille', speed_down: 500, speed_up: 200, popular: true, bundle_includes: ['Internet 500 Mbps', 'Téléphonie résidentielle', 'IPTV 120+ chaînes'] },
|
|
||||||
{ item_code: 'EQ-ROUTER-WIFI6', item_name: 'Routeur Wi-Fi 6', rate: 149.99, billing_type: 'Unique', service_category: 'Équipement', requires_visit: false, description: 'Routeur haute performance, couverture optimale' },
|
|
||||||
{ item_code: 'EQ-MESH-NODE', item_name: 'Noeud Wi-Fi Mesh', rate: 99.99, billing_type: 'Unique', service_category: 'Équipement', requires_visit: false, description: 'Étend la couverture Wi-Fi dans les grandes maisons' },
|
|
||||||
{ item_code: 'EQ-ONT', item_name: 'Terminal fibre optique (ONT)', rate: 0, billing_type: 'Unique', service_category: 'Équipement', requires_visit: true, project_template_id: 'fiber_install', description: 'Inclus avec tout abonnement Internet fibre' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const CATEGORY_COLORS = { Internet: 'indigo', 'Téléphonie': 'teal', Bundle: 'purple', 'Équipement': 'blue-grey' }
|
|
||||||
|
|
||||||
export const TIME_SLOTS = [
|
|
||||||
{ label: 'Matin (8h - 12h)', value: 'AM' },
|
|
||||||
{ label: 'Après-midi (12h - 17h)', value: 'PM' },
|
|
||||||
]
|
|
||||||
|
|
@ -10,9 +10,6 @@
|
||||||
<span v-if="store.customerName" class="text-body2 q-mr-md gt-sm">
|
<span v-if="store.customerName" class="text-body2 q-mr-md gt-sm">
|
||||||
{{ store.customerName }}
|
{{ store.customerName }}
|
||||||
</span>
|
</span>
|
||||||
<q-btn flat round icon="shopping_cart" @click="$router.push('/panier')" title="Panier" class="q-mr-xs">
|
|
||||||
<q-badge v-if="cartStore.itemCount > 0" floating color="orange" :label="cartStore.itemCount" />
|
|
||||||
</q-btn>
|
|
||||||
<q-btn flat round icon="logout" @click="doLogout" title="Déconnexion" />
|
<q-btn flat round icon="logout" @click="doLogout" title="Déconnexion" />
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
|
|
@ -34,13 +31,13 @@
|
||||||
</q-drawer>
|
</q-drawer>
|
||||||
|
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
<!-- Loading state (only block if we expect auth to succeed) -->
|
<!-- Loading state -->
|
||||||
<div v-if="store.loading && !guestMode" class="flex flex-center" style="min-height: 60vh">
|
<div v-if="store.loading" class="flex flex-center" style="min-height: 60vh">
|
||||||
<q-spinner-dots size="48px" color="primary" />
|
<q-spinner-dots size="48px" color="primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error state — only block for auth-required pages, let catalog/cart through -->
|
<!-- Error state -->
|
||||||
<div v-else-if="store.error && requiresAuth" class="flex flex-center" style="min-height: 60vh">
|
<div v-else-if="store.error" class="flex flex-center" style="min-height: 60vh">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<q-icon name="error_outline" size="64px" color="negative" />
|
<q-icon name="error_outline" size="64px" color="negative" />
|
||||||
<div class="text-h6 q-mt-md">{{ store.error }}</div>
|
<div class="text-h6 q-mt-md">{{ store.error }}</div>
|
||||||
|
|
@ -55,29 +52,19 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useCustomerStore } from 'src/stores/customer'
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
import { useCartStore } from 'src/stores/cart'
|
|
||||||
import { logout } from 'src/api/auth'
|
import { logout } from 'src/api/auth'
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const store = useCustomerStore()
|
const store = useCustomerStore()
|
||||||
const cartStore = useCartStore()
|
|
||||||
const drawer = ref(true)
|
const drawer = ref(true)
|
||||||
|
|
||||||
// Pages that work without authentication
|
|
||||||
const publicRoutes = ['catalog', 'cart', 'order-success']
|
|
||||||
const guestMode = computed(() => store.error && !store.customerId)
|
|
||||||
const requiresAuth = computed(() => !publicRoutes.includes(route.name))
|
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: '/', icon: 'dashboard', label: 'Tableau de bord' },
|
{ to: '/', icon: 'dashboard', label: 'Tableau de bord' },
|
||||||
{ to: '/invoices', icon: 'receipt_long', label: 'Factures' },
|
{ to: '/invoices', icon: 'receipt_long', label: 'Factures' },
|
||||||
{ to: '/tickets', icon: 'support_agent', label: 'Support' },
|
{ to: '/tickets', icon: 'support_agent', label: 'Support' },
|
||||||
{ to: '/messages', icon: 'chat', label: 'Messages' },
|
{ to: '/messages', icon: 'chat', label: 'Messages' },
|
||||||
{ to: '/me', icon: 'person', label: 'Mon compte' },
|
{ to: '/me', icon: 'person', label: 'Mon compte' },
|
||||||
{ to: '/catalogue', icon: 'storefront', label: 'Catalogue' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function doLogout () {
|
function doLogout () {
|
||||||
|
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-page class="cart-page q-pa-md">
|
|
||||||
<div style="max-width:900px;margin:0 auto">
|
|
||||||
|
|
||||||
<div v-if="cart.items.length === 0" class="text-center q-pa-xl">
|
|
||||||
<q-icon name="shopping_cart" size="80px" color="grey-4" />
|
|
||||||
<div class="text-h5 text-grey-6 q-mt-lg">Votre panier est vide</div>
|
|
||||||
<q-btn color="primary" unelevated label="Voir le catalogue" class="q-mt-lg" @click="$router.push('/catalogue')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="row items-center q-mb-lg">
|
|
||||||
<q-btn flat round icon="arrow_back" @click="$router.push('/catalogue')" />
|
|
||||||
<div class="text-h5 text-weight-bold q-ml-sm">Mon panier</div>
|
|
||||||
<q-space />
|
|
||||||
<q-btn flat color="negative" label="Vider le panier" icon="delete_sweep" @click="confirmClear" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-col-gutter-lg">
|
|
||||||
<div class="col-12 col-md-7">
|
|
||||||
|
|
||||||
<!-- Cart items -->
|
|
||||||
<q-card flat bordered class="q-mb-md rounded-card">
|
|
||||||
<q-list separator>
|
|
||||||
<q-item v-for="(item, idx) in cart.items" :key="item.item_code" class="q-pa-md">
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label class="text-weight-bold">{{ item.item_name }}</q-item-label>
|
|
||||||
<q-item-label caption>{{ item.billing_type === 'Mensuel' ? 'Mensuel' : 'Achat unique' }}</q-item-label>
|
|
||||||
<q-item-label caption v-if="item.description" class="q-mt-xs">{{ item.description }}</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side class="row items-center q-gutter-sm" style="flex-direction:row">
|
|
||||||
<q-btn flat dense round icon="remove" size="sm" @click="cart.updateQty(idx, item.qty - 1)" />
|
|
||||||
<span class="text-weight-medium q-mx-xs">{{ item.qty }}</span>
|
|
||||||
<q-btn flat dense round icon="add" size="sm" @click="cart.updateQty(idx, item.qty + 1)" />
|
|
||||||
<span class="text-weight-bold q-ml-md" style="min-width:80px;text-align:right">{{ formatPrice(item.rate * item.qty) }}</span>
|
|
||||||
<q-btn flat dense round icon="close" color="negative" size="sm" class="q-ml-sm" @click="cart.removeItem(idx)" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- Installation scheduling -->
|
|
||||||
<q-card v-if="cart.requiresVisit" flat bordered class="q-mb-md rounded-card">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">
|
|
||||||
<q-icon name="calendar_month" class="q-mr-xs" /> Installation requise
|
|
||||||
</div>
|
|
||||||
<div class="text-body2 text-grey-7 q-mb-md">Certains articles nécessitent une visite d'installation.</div>
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div class="col-12 col-sm-6">
|
|
||||||
<q-input v-model="form.preferredDate" type="date" label="Date préférée" outlined dense :min="minDate" :rules="[v => !!v || 'Requis']" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-sm-6">
|
|
||||||
<q-select v-model="form.preferredSlot" :options="TIME_SLOTS" label="Plage horaire" outlined dense emit-value map-options />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- OTP existing customer -->
|
|
||||||
<q-card flat bordered class="q-mb-md rounded-card">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="row items-center q-mb-sm">
|
|
||||||
<q-icon name="person_search" size="22px" color="primary" class="q-mr-sm" />
|
|
||||||
<div class="text-subtitle1 text-weight-bold">Vous êtes déjà client?</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-body2 text-grey-7 q-mb-md">Entrez votre courriel ou téléphone pour associer cette commande à votre compte.</div>
|
|
||||||
|
|
||||||
<div v-if="!otp.verified.value">
|
|
||||||
<div v-if="otp.step.value === 'verify'" class="text-caption text-grey-7 q-mb-xs">
|
|
||||||
Code envoyé par {{ otp.channel.value === 'email' ? 'courriel' : 'SMS' }} au <strong>{{ otp.identifier.value }}</strong>
|
|
||||||
· <a class="cursor-pointer text-primary" style="text-decoration:none" @click="otp.reset()">Modifier</a>
|
|
||||||
</div>
|
|
||||||
<div class="row q-col-gutter-sm items-center">
|
|
||||||
<div class="col">
|
|
||||||
<q-input v-if="otp.step.value === 'idle'" v-model="otp.identifier.value" label="Courriel ou téléphone" outlined dense placeholder="ex: 514-555-1234 ou nom@exemple.com" @keyup.enter="otp.identifier.value.length >= 5 && otp.send()">
|
|
||||||
<template v-slot:prepend><q-icon name="person_search" size="20px" /></template>
|
|
||||||
</q-input>
|
|
||||||
<q-input v-else ref="otpInputRef" v-model="otp.code.value" label="Code de vérification" outlined dense type="tel" maxlength="6" placeholder="000000" input-class="text-weight-bold text-center" input-style="font-size:1.3rem;letter-spacing:8px" @keyup.enter="otp.code.value.length === 6 && otp.verify()">
|
|
||||||
<template v-slot:prepend><q-icon name="lock" size="20px" /></template>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-btn color="primary" unelevated
|
|
||||||
:label="otp.step.value === 'idle' ? 'Envoyer' : 'Valider'"
|
|
||||||
:icon="otp.step.value === 'idle' ? 'send' : 'check'"
|
|
||||||
:loading="otp.step.value === 'idle' ? otp.sending.value : otp.verifying.value"
|
|
||||||
:disable="otp.step.value === 'idle' ? (!otp.identifier.value || otp.identifier.value.length < 5) : otp.code.value.length !== 6"
|
|
||||||
@click="otp.step.value === 'idle' ? otp.send() : otp.verify()" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="otp.step.value === 'verify'" class="q-mt-xs">
|
|
||||||
<q-btn flat dense size="sm" color="grey-7" label="Renvoyer le code" icon="refresh" @click="otp.send()" :loading="otp.sending.value" no-caps />
|
|
||||||
</div>
|
|
||||||
<div v-if="otp.error.value" class="text-negative text-caption q-mt-xs">
|
|
||||||
<q-icon name="error" size="14px" /> {{ otp.error.value }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verified state -->
|
|
||||||
<div v-if="otp.verified.value" class="q-mt-sm">
|
|
||||||
<q-banner dense rounded class="bg-green-1 text-positive">
|
|
||||||
<template v-slot:avatar><q-icon name="verified_user" color="positive" /></template>
|
|
||||||
<strong>{{ form.name }}</strong> — compte vérifié
|
|
||||||
<template v-slot:action><q-btn flat dense label="Changer" @click="otp.reset()" /></template>
|
|
||||||
</q-banner>
|
|
||||||
|
|
||||||
<div v-if="otp.addresses.value.length" class="q-mt-md">
|
|
||||||
<div class="text-caption text-weight-bold q-mb-xs">Vos adresses :</div>
|
|
||||||
<q-list bordered separator class="rounded-borders">
|
|
||||||
<q-item v-for="(addr, i) in otp.addresses.value" :key="i" clickable v-ripple
|
|
||||||
@click="addr$.selectCustomerAddr(addr)"
|
|
||||||
:class="form.address === addr.address ? 'bg-blue-1' : ''">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon :name="form.address === addr.address ? 'radio_button_checked' : 'radio_button_unchecked'" :color="form.address === addr.address ? 'primary' : 'grey-5'" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>{{ addr.address }}</q-item-label>
|
|
||||||
<q-item-label caption>{{ addr.city }}{{ addr.postal_code ? ' — ' + addr.postal_code : '' }}</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
<div class="text-caption text-grey-6 q-mt-xs">Ou recherchez une nouvelle adresse ci-dessous</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="otp.step.value === 'idle' && !otp.verified.value" class="text-caption text-grey-6 q-mt-sm">
|
|
||||||
Pas encore client? Continuez comme nouveau client ci-dessous.
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- Customer info -->
|
|
||||||
<q-card flat bordered class="q-mb-md rounded-card">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-subtitle1 text-weight-bold q-mb-md"><q-icon name="person" class="q-mr-xs" /> Vos coordonnées</div>
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div class="col-12 col-sm-6">
|
|
||||||
<q-input v-model="form.name" label="Nom complet" outlined dense :rules="[v => !!v || 'Requis']" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-sm-6">
|
|
||||||
<q-input v-model="form.phone" label="Téléphone" outlined dense mask="(###) ###-####" :rules="[v => !!v || 'Requis']" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<q-input v-model="form.email" label="Courriel" outlined dense type="email" :rules="[v => !!v || 'Requis', v => /.+@.+\..+/.test(v) || 'Courriel invalide']" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<q-input v-model="addr$.query.value" label="Adresse d'installation / livraison" outlined dense :loading="addr$.searching.value" @update:model-value="addr$.onInput" :rules="[() => !!form.address || 'Sélectionnez une adresse']">
|
|
||||||
<template v-slot:prepend><q-icon name="place" /></template>
|
|
||||||
<template v-slot:append><q-icon v-if="form.address" name="check_circle" color="positive" /></template>
|
|
||||||
</q-input>
|
|
||||||
<q-list v-if="addr$.results.value.length && !addr$.selected.value" bordered separator class="addr-dropdown">
|
|
||||||
<q-item v-for="(a, i) in addr$.results.value" :key="i" clickable v-ripple @click="addr$.selectResult(a)">
|
|
||||||
<q-item-section avatar><q-icon name="location_on" :color="a.fiber_available ? 'positive' : 'grey-5'" /></q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>{{ a.adresse_formatee }}</q-item-label>
|
|
||||||
<q-item-label caption>{{ a.nom_municipalite }}{{ a.code_postal ? ' — ' + a.code_postal : '' }} <q-badge v-if="a.fiber_available" color="positive" label="Fibre" class="q-ml-xs" /></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
<div v-if="addr$.selected.value && form.address" class="text-caption text-grey-7 q-mt-xs q-ml-sm">
|
|
||||||
<q-icon name="check" color="positive" size="14px" /> {{ form.address }}{{ form.city ? ', ' + form.city : '' }}{{ form.postalCode ? ' ' + form.postalCode : '' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Order summary -->
|
|
||||||
<div class="col-12 col-md-5">
|
|
||||||
<q-card flat bordered class="summary-card rounded-card" style="position:sticky;top:80px">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-subtitle1 text-weight-bold q-mb-md">Sommaire de commande</div>
|
|
||||||
<div v-for="item in cart.items" :key="item.item_code" class="row justify-between q-mb-xs text-body2">
|
|
||||||
<span>{{ item.item_name }} x{{ item.qty }}</span>
|
|
||||||
<span>{{ formatPrice(item.rate * item.qty) }}</span>
|
|
||||||
</div>
|
|
||||||
<q-separator class="q-my-md" />
|
|
||||||
<div v-if="cart.recurringTotal > 0" class="row justify-between q-mb-xs">
|
|
||||||
<span class="text-body2">Mensuel</span>
|
|
||||||
<span class="text-body2 text-weight-medium">{{ formatPrice(cart.recurringTotal) }}/mois</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="cart.onetimeTotal > 0" class="row justify-between q-mb-xs">
|
|
||||||
<span class="text-body2">Achat unique</span>
|
|
||||||
<span class="text-body2 text-weight-medium">{{ formatPrice(cart.onetimeTotal) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="row justify-between q-mb-xs">
|
|
||||||
<span class="text-body2">Sous-total</span>
|
|
||||||
<span class="text-body2 text-weight-medium">{{ formatPrice(cart.subtotal) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="row justify-between q-mb-xs">
|
|
||||||
<span class="text-body2 text-grey-6">TPS + TVQ (14,975%)</span>
|
|
||||||
<span class="text-body2 text-grey-6">{{ formatPrice(cart.taxAmount) }}</span>
|
|
||||||
</div>
|
|
||||||
<q-separator class="q-my-md" />
|
|
||||||
<div class="row justify-between">
|
|
||||||
<span class="text-subtitle1 text-weight-bold">Total</span>
|
|
||||||
<span class="text-subtitle1 text-weight-bold text-primary">{{ formatPrice(cart.grandTotal) }}</span>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<q-separator />
|
|
||||||
|
|
||||||
<q-card-section>
|
|
||||||
<q-checkbox v-model="form.acceptTerms" dense class="q-mb-md">
|
|
||||||
<template #default>
|
|
||||||
<span class="text-body2">
|
|
||||||
J'accepte les <a href="#" class="text-primary" @click.prevent>conditions d'utilisation</a>
|
|
||||||
et la <a href="#" class="text-primary" @click.prevent>politique de confidentialité</a>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</q-checkbox>
|
|
||||||
<div class="text-caption text-grey-6 q-mb-md">Aucun paiement requis maintenant. Vous recevrez votre première facture après l'activation du service.</div>
|
|
||||||
<q-btn color="primary" unelevated class="full-width" size="lg" label="Commander" icon="check_circle" :loading="submitting" :disable="!canSubmit" @click="handleSubmit" />
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useQuasar } from 'quasar'
|
|
||||||
import { useCartStore } from 'src/stores/cart'
|
|
||||||
import { useCustomerStore } from 'src/stores/customer'
|
|
||||||
import { submitOrder } from 'src/api/catalog'
|
|
||||||
import { useOTP } from 'src/composables/useOTP'
|
|
||||||
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
|
||||||
import { formatPrice, minBookingDate } from 'src/utils/format'
|
|
||||||
import { TIME_SLOTS } from 'src/data/catalog'
|
|
||||||
|
|
||||||
const $q = useQuasar()
|
|
||||||
const router = useRouter()
|
|
||||||
const cart = useCartStore()
|
|
||||||
const customer = useCustomerStore()
|
|
||||||
const submitting = ref(false)
|
|
||||||
const minDate = computed(() => minBookingDate())
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
name: '', phone: '', email: '', address: '', city: '', province: 'QC',
|
|
||||||
postalCode: '', latitude: null, longitude: null,
|
|
||||||
preferredDate: minBookingDate(), preferredSlot: 'AM',
|
|
||||||
acceptTerms: false, customer_id: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const addr$ = useAddressSearch(form)
|
|
||||||
const otp = useOTP(form, {
|
|
||||||
onVerified (result) {
|
|
||||||
if (result.addresses?.length) addr$.selectCustomerAddr(result.addresses[0])
|
|
||||||
$q.notify({ message: 'Compte vérifié!', color: 'positive', icon: 'verified_user' })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (customer.customerName) form.name = customer.customerName
|
|
||||||
if (customer.email) form.email = customer.email
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
|
||||||
const base = form.name && form.phone && form.email && form.address && form.acceptTerms
|
|
||||||
return cart.requiresVisit ? base && form.preferredDate : base
|
|
||||||
})
|
|
||||||
|
|
||||||
function confirmClear () {
|
|
||||||
$q.dialog({
|
|
||||||
title: 'Vider le panier', message: 'Voulez-vous vraiment retirer tous les articles?',
|
|
||||||
cancel: { label: 'Annuler', flat: true }, ok: { label: 'Vider', color: 'negative' },
|
|
||||||
}).onOk(() => cart.clearCart())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit () {
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
customer_id: form.customer_id || customer.customerId || null,
|
|
||||||
items: cart.items.map(({ item_code, item_name, qty, rate, billing_type, requires_visit, project_template_id }) =>
|
|
||||||
({ item_code, item_name, qty, rate, billing_type, requires_visit, project_template_id })),
|
|
||||||
contact: { name: form.name, phone: form.phone, email: form.email, address: form.address, city: form.city, province: form.province, postal_code: form.postalCode, latitude: form.latitude, longitude: form.longitude },
|
|
||||||
installation: cart.requiresVisit ? { preferred_date: form.preferredDate, preferred_slot: form.preferredSlot } : null,
|
|
||||||
totals: { subtotal: cart.subtotal, tax: cart.taxAmount, grand_total: cart.grandTotal, recurring_monthly: cart.recurringTotal, onetime: cart.onetimeTotal },
|
|
||||||
}
|
|
||||||
const result = await submitOrder(payload)
|
|
||||||
router.push({ name: 'order-success', query: { order: result.order_id }, state: { orderData: JSON.stringify({ ...payload, order_id: result.order_id }) } })
|
|
||||||
cart.clearCart()
|
|
||||||
} catch (e) {
|
|
||||||
$q.notify({ message: 'Erreur : ' + (e.message || 'Veuillez réessayer'), color: 'negative', icon: 'error' })
|
|
||||||
} finally { submitting.value = false }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.cart-page { padding-bottom: 32px }
|
|
||||||
.rounded-card { border-radius: 12px }
|
|
||||||
.summary-card { background: #fafbff }
|
|
||||||
.addr-dropdown { position: relative; z-index: 10; background: #fff; box-shadow: 0 4px 16px rgba(0,0,0,.1); max-height: 280px; overflow-y: auto; border-radius: 0 0 8px 8px; margin-top: -8px }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-page class="catalog-page">
|
|
||||||
<div class="catalog-hero q-pa-lg text-center text-white">
|
|
||||||
<div class="text-h4 text-weight-bold q-mb-sm">Nos forfaits et services</div>
|
|
||||||
<div class="text-subtitle1" style="opacity:.85">Internet fibre ultra-rapide, téléphonie et plus</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="q-px-md q-pt-md" style="max-width:1200px;margin:0 auto">
|
|
||||||
<q-tabs v-model="activeTab" dense active-color="primary" indicator-color="primary" align="left" narrow-indicator class="text-grey-7">
|
|
||||||
<q-tab name="all" label="Tous" />
|
|
||||||
<q-tab name="Internet" label="Internet" />
|
|
||||||
<q-tab name="Téléphonie" label="Téléphonie" />
|
|
||||||
<q-tab name="Bundle" label="Bundles" />
|
|
||||||
<q-tab name="Équipement" label="Équipement" />
|
|
||||||
</q-tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="q-pa-md" style="max-width:1200px;margin:0 auto">
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div v-for="p in filteredProducts" :key="p.item_code" class="col-12 col-sm-6 col-md-4 col-lg-3">
|
|
||||||
<q-card class="product-card full-height" flat bordered>
|
|
||||||
<q-badge v-if="p.popular" color="orange" floating label="Populaire" class="text-weight-bold" style="top:12px;right:12px;font-size:.75rem" />
|
|
||||||
<q-card-section>
|
|
||||||
<q-chip :color="CATEGORY_COLORS[p.service_category] || 'grey'" text-color="white" size="sm" dense class="q-mb-sm">{{ p.service_category }}</q-chip>
|
|
||||||
<div class="text-h6 text-weight-bold q-mb-xs">{{ p.item_name }}</div>
|
|
||||||
<div v-if="p.speed_down" class="speed-bar q-mb-sm">
|
|
||||||
<div class="row items-center q-gutter-xs">
|
|
||||||
<q-icon name="speed" color="primary" size="20px" />
|
|
||||||
<span class="text-weight-medium text-primary">{{ p.speed_down }} / {{ p.speed_up }} Mbps</span>
|
|
||||||
</div>
|
|
||||||
<q-linear-progress :value="p.speed_down / 1000" color="primary" class="q-mt-xs" rounded size="6px" />
|
|
||||||
</div>
|
|
||||||
<div class="text-body2 text-grey-7 q-mb-md" style="min-height:40px">{{ p.description }}</div>
|
|
||||||
<div v-if="p.bundle_includes?.length" class="q-mb-md">
|
|
||||||
<div class="text-caption text-weight-medium text-grey-8 q-mb-xs">Inclus :</div>
|
|
||||||
<div v-for="inc in p.bundle_includes" :key="inc" class="row items-center q-gutter-xs q-mb-xs">
|
|
||||||
<q-icon name="check_circle" color="positive" size="16px" />
|
|
||||||
<span class="text-body2">{{ inc }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row items-end q-mb-sm">
|
|
||||||
<span class="text-h5 text-weight-bold text-primary">{{ formatPrice(p.rate) }}</span>
|
|
||||||
<span class="text-body2 text-grey-6 q-ml-xs q-mb-xs">{{ p.billing_type === 'Mensuel' ? '/mois' : 'unique' }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="p.requires_visit" class="text-caption text-grey-6 q-mb-sm">
|
|
||||||
<q-icon name="build" size="14px" class="q-mr-xs" /> Installation requise
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-separator />
|
|
||||||
<q-card-actions class="q-pa-md">
|
|
||||||
<q-btn color="primary" unelevated class="full-width" :label="isInCart(p.item_code) ? 'Ajouté ✓' : 'Ajouter au panier'" :outline="isInCart(p.item_code)" @click="addToCart(p)" />
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!filteredProducts.length" class="text-center q-pa-xl text-grey-5">
|
|
||||||
<q-icon name="inventory_2" size="64px" />
|
|
||||||
<div class="text-h6 q-mt-md">Aucun produit dans cette catégorie</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<transition name="slide-up">
|
|
||||||
<div v-if="cart.itemCount > 0" class="cart-bar">
|
|
||||||
<div class="cart-bar-inner row items-center justify-between q-px-md">
|
|
||||||
<div class="row items-center q-gutter-sm">
|
|
||||||
<q-icon name="shopping_cart" size="24px" color="white" />
|
|
||||||
<span class="text-white text-weight-medium">{{ cart.itemCount }} article{{ cart.itemCount > 1 ? 's' : '' }}</span>
|
|
||||||
<q-separator vertical dark class="q-mx-sm" />
|
|
||||||
<span v-if="cart.recurringTotal > 0" class="text-white">{{ formatPrice(cart.recurringTotal) }}/mois</span>
|
|
||||||
<span v-if="cart.onetimeTotal > 0" class="text-white q-ml-sm">+ {{ formatPrice(cart.onetimeTotal) }} unique</span>
|
|
||||||
</div>
|
|
||||||
<q-btn color="white" text-color="primary" unelevated label="Voir le panier" icon-right="arrow_forward" @click="$router.push('/panier')" class="text-weight-bold" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useCartStore } from 'src/stores/cart'
|
|
||||||
import { useQuasar } from 'quasar'
|
|
||||||
import { CATALOG, CATEGORY_COLORS } from 'src/data/catalog'
|
|
||||||
import { formatPrice } from 'src/utils/format'
|
|
||||||
|
|
||||||
const $q = useQuasar()
|
|
||||||
const cart = useCartStore()
|
|
||||||
const activeTab = ref('all')
|
|
||||||
|
|
||||||
const filteredProducts = computed(() =>
|
|
||||||
activeTab.value === 'all' ? CATALOG : CATALOG.filter(p => p.service_category === activeTab.value)
|
|
||||||
)
|
|
||||||
|
|
||||||
const isInCart = (code) => cart.items.some(i => i.item_code === code)
|
|
||||||
|
|
||||||
function addToCart (product) {
|
|
||||||
cart.addItem(product)
|
|
||||||
$q.notify({ message: `${product.item_name} ajouté au panier`, color: 'positive', icon: 'check_circle', position: 'bottom', timeout: 1500 })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.catalog-hero { background: linear-gradient(135deg, #3949ab 0%, #1a237e 100%); margin: -16px -16px 0 -16px; padding: 40px 16px 32px }
|
|
||||||
.product-card { border-radius: 12px; transition: box-shadow 0.2s, transform 0.2s; &:hover { box-shadow: 0 8px 30px rgba(0,0,0,.1); transform: translateY(-2px) } }
|
|
||||||
.cart-bar { position: fixed; bottom: 0; left: 0; right: 0; z-index: 2000; background: linear-gradient(135deg, #3949ab 0%, #1a237e 100%); box-shadow: 0 -4px 20px rgba(0,0,0,.2) }
|
|
||||||
.cart-bar-inner { max-width: 1200px; margin: 0 auto; min-height: 56px }
|
|
||||||
.slide-up-enter-active, .slide-up-leave-active { transition: transform 0.3s ease }
|
|
||||||
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%) }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-page class="flex flex-center q-pa-md">
|
|
||||||
<div style="max-width:600px;width:100%">
|
|
||||||
<q-card flat bordered style="border-radius:16px" class="text-center q-pa-lg">
|
|
||||||
<!-- Success icon -->
|
|
||||||
<div class="q-mb-lg">
|
|
||||||
<div class="success-circle q-mx-auto">
|
|
||||||
<q-icon name="check" size="48px" color="white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-h4 text-weight-bold q-mb-sm">Commande confirmée!</div>
|
|
||||||
<div class="text-subtitle1 text-grey-7 q-mb-lg">
|
|
||||||
Merci pour votre commande. Voici votre confirmation.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Order number -->
|
|
||||||
<q-card flat bordered class="q-pa-md q-mb-lg" style="border-radius:12px;background:#f5f7ff">
|
|
||||||
<div class="text-caption text-grey-6">Numéro de commande</div>
|
|
||||||
<div class="text-h5 text-weight-bold text-primary">{{ orderId }}</div>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- Order summary -->
|
|
||||||
<div v-if="orderData" class="text-left q-mb-lg">
|
|
||||||
<div class="text-subtitle2 text-weight-bold q-mb-sm">Articles commandés</div>
|
|
||||||
<q-list dense separator>
|
|
||||||
<q-item v-for="item in orderData.items" :key="item.item_code">
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>{{ item.item_name }}</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
{{ item.billing_type === 'Mensuel' ? 'Mensuel' : 'Achat unique' }}
|
|
||||||
<span v-if="item.qty > 1"> x{{ item.qty }}</span>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<span class="text-weight-medium">{{ formatPrice(item.rate * item.qty) }}</span>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
|
|
||||||
<q-separator class="q-my-sm" />
|
|
||||||
<div class="row justify-between q-px-md">
|
|
||||||
<span class="text-weight-bold">Total</span>
|
|
||||||
<span class="text-weight-bold text-primary">
|
|
||||||
{{ formatPrice(orderData.totals.grand_total) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Installation notice -->
|
|
||||||
<q-banner
|
|
||||||
v-if="orderData && orderData.installation"
|
|
||||||
class="q-mb-lg text-left"
|
|
||||||
rounded
|
|
||||||
style="background:#e8f5e9"
|
|
||||||
>
|
|
||||||
<template #avatar>
|
|
||||||
<q-icon name="engineering" color="positive" />
|
|
||||||
</template>
|
|
||||||
<div class="text-weight-medium">Installation prévue</div>
|
|
||||||
<div class="text-body2 text-grey-8">
|
|
||||||
Date souhaitée : {{ formatDate(orderData.installation.preferred_date) }}
|
|
||||||
({{ orderData.installation.preferred_slot === 'AM' ? 'Matin' : 'Après-midi' }})
|
|
||||||
</div>
|
|
||||||
<div class="text-body2 text-grey-7 q-mt-xs">
|
|
||||||
Nous vous contacterons pour confirmer le rendez-vous d'installation.
|
|
||||||
</div>
|
|
||||||
</q-banner>
|
|
||||||
|
|
||||||
<!-- No installation -->
|
|
||||||
<q-banner
|
|
||||||
v-else
|
|
||||||
class="q-mb-lg text-left"
|
|
||||||
rounded
|
|
||||||
style="background:#e3f2fd"
|
|
||||||
>
|
|
||||||
<template #avatar>
|
|
||||||
<q-icon name="local_shipping" color="primary" />
|
|
||||||
</template>
|
|
||||||
<div class="text-body2">
|
|
||||||
Votre service sera activé dans les prochaines 24 heures. Vous recevrez un courriel de confirmation.
|
|
||||||
</div>
|
|
||||||
</q-banner>
|
|
||||||
|
|
||||||
<!-- Contact info -->
|
|
||||||
<q-card flat bordered class="q-pa-md q-mb-lg text-left" style="border-radius:12px">
|
|
||||||
<div class="text-subtitle2 text-weight-bold q-mb-sm">Besoin d'aide?</div>
|
|
||||||
<div class="text-body2 q-mb-xs">
|
|
||||||
<q-icon name="email" class="q-mr-xs" size="16px" />
|
|
||||||
support@gigafibre.ca
|
|
||||||
</div>
|
|
||||||
<div class="text-body2 q-mb-xs">
|
|
||||||
<q-icon name="phone" class="q-mr-xs" size="16px" />
|
|
||||||
1-888-GIGAFIBRE
|
|
||||||
</div>
|
|
||||||
<div class="text-body2">
|
|
||||||
<q-icon name="schedule" class="q-mr-xs" size="16px" />
|
|
||||||
Lundi - Vendredi, 8h - 20h
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="row q-gutter-sm justify-center">
|
|
||||||
<q-btn
|
|
||||||
color="primary"
|
|
||||||
unelevated
|
|
||||||
label="Retour au portail"
|
|
||||||
icon="home"
|
|
||||||
@click="$router.push('/')"
|
|
||||||
/>
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="primary"
|
|
||||||
label="Voir le catalogue"
|
|
||||||
icon="storefront"
|
|
||||||
@click="$router.push('/catalogue')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const orderId = ref(route.query.order || 'N/A')
|
|
||||||
const orderData = ref(null)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Try to recover order data from navigation state or sessionStorage
|
|
||||||
try {
|
|
||||||
const stateRaw = window.history.state?.orderData
|
|
||||||
if (stateRaw) {
|
|
||||||
orderData.value = JSON.parse(stateRaw)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Order data not available — show minimal confirmation
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatPrice (val) {
|
|
||||||
return (val || 0).toFixed(2) + ' $'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate (dateStr) {
|
|
||||||
if (!dateStr) return ''
|
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
|
||||||
return d.toLocaleDateString('fr-CA', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.success-circle {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #43a047, #2e7d32);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
animation: pop-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pop-in {
|
|
||||||
0% { transform: scale(0); opacity: 0; }
|
|
||||||
100% { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -12,9 +12,6 @@ const routes = [
|
||||||
{ path: 'tickets/:name', name: 'ticket-detail', component: () => import('pages/TicketDetailPage.vue') },
|
{ path: 'tickets/:name', name: 'ticket-detail', component: () => import('pages/TicketDetailPage.vue') },
|
||||||
{ path: 'messages', name: 'messages', component: () => import('pages/MessagesPage.vue') },
|
{ path: 'messages', name: 'messages', component: () => import('pages/MessagesPage.vue') },
|
||||||
{ path: 'me', name: 'account', component: () => import('pages/AccountPage.vue') },
|
{ path: 'me', name: 'account', component: () => import('pages/AccountPage.vue') },
|
||||||
{ path: 'catalogue', name: 'catalog', component: () => import('pages/CatalogPage.vue') },
|
|
||||||
{ path: 'panier', name: 'cart', component: () => import('pages/CartPage.vue') },
|
|
||||||
{ path: 'commande/confirmation', name: 'order-success', component: () => import('pages/OrderSuccessPage.vue') },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'gigafibre_cart'
|
|
||||||
|
|
||||||
function loadFromStorage () {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
|
||||||
return raw ? JSON.parse(raw) : []
|
|
||||||
} catch { return [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCartStore = defineStore('cart', () => {
|
|
||||||
const items = ref(loadFromStorage())
|
|
||||||
const taxRate = 0.14975 // TPS 5% + TVQ 9.975%
|
|
||||||
|
|
||||||
// Persist to localStorage on every change
|
|
||||||
watch(items, (val) => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
function addItem (product) {
|
|
||||||
const existing = items.value.find(i => i.item_code === product.item_code)
|
|
||||||
if (existing) {
|
|
||||||
existing.qty += 1
|
|
||||||
} else {
|
|
||||||
items.value.push({
|
|
||||||
item_code: product.item_code,
|
|
||||||
item_name: product.item_name,
|
|
||||||
rate: product.rate,
|
|
||||||
qty: 1,
|
|
||||||
billing_type: product.billing_type,
|
|
||||||
service_category: product.service_category,
|
|
||||||
requires_visit: product.requires_visit || false,
|
|
||||||
project_template_id: product.project_template_id || null,
|
|
||||||
description: product.description || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeItem (index) {
|
|
||||||
items.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateQty (index, qty) {
|
|
||||||
if (qty < 1) {
|
|
||||||
removeItem(index)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items.value[index].qty = qty
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCart () {
|
|
||||||
items.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemCount = computed(() =>
|
|
||||||
items.value.reduce((sum, i) => sum + i.qty, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const onetimeTotal = computed(() =>
|
|
||||||
items.value
|
|
||||||
.filter(i => i.billing_type !== 'Mensuel')
|
|
||||||
.reduce((sum, i) => sum + i.rate * i.qty, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const recurringTotal = computed(() =>
|
|
||||||
items.value
|
|
||||||
.filter(i => i.billing_type === 'Mensuel')
|
|
||||||
.reduce((sum, i) => sum + i.rate * i.qty, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const subtotal = computed(() => onetimeTotal.value + recurringTotal.value)
|
|
||||||
|
|
||||||
const taxAmount = computed(() => subtotal.value * taxRate)
|
|
||||||
|
|
||||||
const grandTotal = computed(() => subtotal.value + taxAmount.value)
|
|
||||||
|
|
||||||
const requiresVisit = computed(() =>
|
|
||||||
items.value.some(i => i.requires_visit),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
taxRate,
|
|
||||||
addItem,
|
|
||||||
removeItem,
|
|
||||||
updateQty,
|
|
||||||
clearCart,
|
|
||||||
itemCount,
|
|
||||||
onetimeTotal,
|
|
||||||
recurringTotal,
|
|
||||||
subtotal,
|
|
||||||
taxAmount,
|
|
||||||
grandTotal,
|
|
||||||
requiresVisit,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
export const formatPrice = (val) => val.toFixed(2) + ' $'
|
|
||||||
|
|
||||||
// Strip +1/1 prefix from E.164 phone → 10-digit local format for mask
|
|
||||||
export function normalizePhone (raw) {
|
|
||||||
if (!raw) return ''
|
|
||||||
const digits = raw.replace(/\D/g, '')
|
|
||||||
return digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimum booking date (J+3)
|
|
||||||
export function minBookingDate () {
|
|
||||||
const d = new Date()
|
|
||||||
d.setDate(d.getDate() + 3)
|
|
||||||
return d.toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
4
apps/dispatch/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
10
apps/dispatch/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parserOptions: { ecmaVersion: 'latest' },
|
||||||
|
env: { browser: true },
|
||||||
|
extends: ['plugin:vue/vue3-essential', 'eslint:recommended'],
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': 'warn',
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
},
|
||||||
|
}
|
||||||
6
apps/dispatch/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.quasar/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
14
apps/dispatch/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first (cached layer)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# The built app lives in /app/dist/pwa/
|
||||||
|
# It is extracted by deploy.sh using `docker cp`
|
||||||
29
apps/dispatch/deploy-fast.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# deploy-fast.sh — Build locally + copy to ERPNext container (no Docker build)
|
||||||
|
#
|
||||||
|
# ~5-8s vs ~30s with deploy.sh
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# chmod +x deploy-fast.sh
|
||||||
|
# ./deploy-fast.sh
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONTAINER="frappe_docker-frontend-1"
|
||||||
|
DEST="/home/frappe/frappe-bench/sites/assets/dispatch-app"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "==> Building PWA locally..."
|
||||||
|
npx quasar build -m pwa
|
||||||
|
|
||||||
|
echo "==> Deploying to $CONTAINER..."
|
||||||
|
docker exec "$CONTAINER" mkdir -p "$DEST"
|
||||||
|
docker cp "$SCRIPT_DIR/dist/pwa/." "$CONTAINER:$DEST/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! (~$(date +%Ss))"
|
||||||
|
echo " Dispatch : http://localhost:8080/assets/dispatch-app/index.html"
|
||||||
|
echo " Mobile : http://localhost:8080/assets/dispatch-app/index.html#/mobile"
|
||||||
41
apps/dispatch/deploy.sh
Executable file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# deploy.sh — Build the Quasar PWA and deploy to ERPNext Docker
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# chmod +x deploy.sh
|
||||||
|
# ./deploy.sh
|
||||||
|
#
|
||||||
|
# Accès après déploiement :
|
||||||
|
# http://localhost:8080/assets/dispatch-app/
|
||||||
|
# http://localhost:8080/assets/dispatch-app/#/mobile
|
||||||
|
#
|
||||||
|
# To change the target container or path, edit CONTAINER and DEST below.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONTAINER="frappe_docker-frontend-1"
|
||||||
|
DEST="/home/frappe/frappe-bench/sites/assets/dispatch-app"
|
||||||
|
IMAGE="dispatch-app-builder"
|
||||||
|
|
||||||
|
echo "==> Building Docker image..."
|
||||||
|
docker build -t "$IMAGE" "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "==> Extracting build artifacts..."
|
||||||
|
TMPDIR="$(mktemp -d)"
|
||||||
|
# Create a temporary container (not running) to copy files out
|
||||||
|
CID=$(docker create "$IMAGE")
|
||||||
|
docker cp "$CID:/app/dist/pwa/." "$TMPDIR/"
|
||||||
|
docker rm "$CID"
|
||||||
|
|
||||||
|
echo "==> Deploying to ERPNext container ($CONTAINER:$DEST)..."
|
||||||
|
docker exec "$CONTAINER" mkdir -p "$DEST"
|
||||||
|
docker cp "$TMPDIR/." "$CONTAINER:$DEST/"
|
||||||
|
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done!"
|
||||||
|
echo " Dispatch : http://localhost:8080/assets/dispatch-app/index.html"
|
||||||
|
echo " Mobile : http://localhost:8080/assets/dispatch-app/index.html#/mobile"
|
||||||
23
apps/dispatch/frappe-setup/add_start_time_field.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""
|
||||||
|
Add start_time field to Dispatch Job doctype
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
def run():
|
||||||
|
meta = frappe.get_meta('Dispatch Job')
|
||||||
|
if meta.has_field('start_time'):
|
||||||
|
print("✓ Field 'start_time' already exists on Dispatch Job")
|
||||||
|
return
|
||||||
|
|
||||||
|
doc = frappe.get_doc('DocType', 'Dispatch Job')
|
||||||
|
doc.append('fields', {
|
||||||
|
'fieldname': 'start_time',
|
||||||
|
'fieldtype': 'Time',
|
||||||
|
'label': 'Heure de début',
|
||||||
|
'insert_after': 'scheduled_date',
|
||||||
|
})
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print("✓ Field 'start_time' added to Dispatch Job")
|
||||||
|
|
||||||
|
run()
|
||||||
99
apps/dispatch/frappe-setup/create_dispatch_settings.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""
|
||||||
|
Dispatch Settings — création du DocType Single dans ERPNext/Frappe
|
||||||
|
==================================================================
|
||||||
|
Exécution (depuis le host) :
|
||||||
|
|
||||||
|
docker cp frappe-setup/create_dispatch_settings.py frappe_docker-backend-1:/home/frappe/
|
||||||
|
docker exec frappe_docker-backend-1 bash -c \
|
||||||
|
"cd /home/frappe/frappe-bench && bench --site $(bench --site-list | head -1) execute /home/frappe/create_dispatch_settings.py"
|
||||||
|
|
||||||
|
Ou directement dans la console bench :
|
||||||
|
bench --site <site> console
|
||||||
|
>>> exec(open('/home/frappe/create_dispatch_settings.py').read())
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
# ── ERPNext / Frappe ─────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'erp_section', 'fieldtype': 'Section Break', 'label': 'ERPNext / Frappe'},
|
||||||
|
{'fieldname': 'erp_url', 'fieldtype': 'Data', 'label': 'URL du serveur',
|
||||||
|
'description': 'Ex: http://localhost:8080 ou https://erp.monentreprise.com',
|
||||||
|
'default': 'http://localhost:8080'},
|
||||||
|
{'fieldname': 'erp_api_key', 'fieldtype': 'Data', 'label': 'API Key',
|
||||||
|
'description': 'Profil utilisateur ERPNext → API Access → API Key'},
|
||||||
|
{'fieldname': 'erp_api_secret', 'fieldtype': 'Password', 'label': 'API Secret'},
|
||||||
|
|
||||||
|
# ── Mapbox ───────────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'mapbox_section', 'fieldtype': 'Section Break', 'label': 'Mapbox'},
|
||||||
|
{'fieldname': 'mapbox_token', 'fieldtype': 'Data', 'label': 'Token public (pk_)',
|
||||||
|
'description': 'Token public — visible dans le navigateur, limitez le scope dans le dashboard Mapbox',
|
||||||
|
'default': 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'},
|
||||||
|
|
||||||
|
# ── Twilio (SMS) ─────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'twilio_section', 'fieldtype': 'Section Break', 'label': 'Twilio — SMS'},
|
||||||
|
{'fieldname': 'twilio_account_sid', 'fieldtype': 'Data', 'label': 'Account SID',
|
||||||
|
'description': 'Commence par AC — console.twilio.com'},
|
||||||
|
{'fieldname': 'twilio_auth_token', 'fieldtype': 'Password', 'label': 'Auth Token'},
|
||||||
|
{'fieldname': 'twilio_from_number', 'fieldtype': 'Data', 'label': 'Numéro expéditeur',
|
||||||
|
'description': 'Format E.164 : +15141234567'},
|
||||||
|
|
||||||
|
# ── Stripe ───────────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'stripe_section', 'fieldtype': 'Section Break', 'label': 'Stripe — Paiements'},
|
||||||
|
{'fieldname': 'stripe_mode', 'fieldtype': 'Select', 'label': 'Mode',
|
||||||
|
'options': 'test\nlive', 'default': 'test'},
|
||||||
|
{'fieldname': 'stripe_publishable_key','fieldtype': 'Data', 'label': 'Clé publique (pk_)'},
|
||||||
|
{'fieldname': 'stripe_secret_key', 'fieldtype': 'Password', 'label': 'Clé secrète (sk_)'},
|
||||||
|
{'fieldname': 'stripe_webhook_secret', 'fieldtype': 'Password', 'label': 'Webhook Secret (whsec_)'},
|
||||||
|
|
||||||
|
# ── n8n ──────────────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'n8n_section', 'fieldtype': 'Section Break', 'label': 'n8n — Automatisation'},
|
||||||
|
{'fieldname': 'n8n_url', 'fieldtype': 'Data', 'label': 'URL n8n',
|
||||||
|
'default': 'http://localhost:5678'},
|
||||||
|
{'fieldname': 'n8n_api_key', 'fieldtype': 'Password', 'label': 'API Key n8n'},
|
||||||
|
{'fieldname': 'n8n_webhook_base','fieldtype': 'Data', 'label': 'Base URL webhooks',
|
||||||
|
'description': 'Ex: http://localhost:5678/webhook — préfixe des webhooks ERPNext → n8n',
|
||||||
|
'default': 'http://localhost:5678/webhook'},
|
||||||
|
|
||||||
|
# ── Templates SMS ────────────────────────────────────────────────────────
|
||||||
|
{'fieldname': 'sms_section', 'fieldtype': 'Section Break', 'label': 'Templates SMS'},
|
||||||
|
{'fieldname': 'sms_enroute', 'fieldtype': 'Text', 'label': 'Technicien en route',
|
||||||
|
'default': 'Bonjour {client_name}, votre technicien {tech_name} est en route et arrivera dans environ {eta} minutes. Réf: {job_id}'},
|
||||||
|
{'fieldname': 'sms_completed', 'fieldtype': 'Text', 'label': 'Service complété',
|
||||||
|
'default': 'Bonjour {client_name}, votre service ({job_id}) a été complété avec succès. Merci de votre confiance !'},
|
||||||
|
{'fieldname': 'sms_assigned', 'fieldtype': 'Text', 'label': 'Job assigné (technicien)',
|
||||||
|
'default': 'Nouveau job assigné : {job_id} — {client_name}, {address}. Durée estimée : {duration}h.'},
|
||||||
|
]
|
||||||
|
|
||||||
|
PERMISSIONS = [
|
||||||
|
{'role': 'System Manager', 'read': 1, 'write': 1, 'create': 1, 'delete': 1},
|
||||||
|
{'role': 'Administrator', 'read': 1, 'write': 1, 'create': 1, 'delete': 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_dispatch_settings():
|
||||||
|
if frappe.db.exists('DocType', 'Dispatch Settings'):
|
||||||
|
print("✓ DocType 'Dispatch Settings' existe déjà")
|
||||||
|
print(" UI : ERPNext Desk → Dispatch Settings")
|
||||||
|
print(" API : /api/resource/Dispatch Settings/Dispatch Settings")
|
||||||
|
return
|
||||||
|
|
||||||
|
doc = frappe.new_doc('DocType')
|
||||||
|
doc.update({
|
||||||
|
'name': 'Dispatch Settings',
|
||||||
|
'module': 'Core',
|
||||||
|
'custom': 1,
|
||||||
|
'is_single': 1,
|
||||||
|
'track_changes': 0,
|
||||||
|
'fields': FIELDS,
|
||||||
|
'permissions': PERMISSIONS,
|
||||||
|
})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
print("✓ DocType 'Dispatch Settings' créé avec succès")
|
||||||
|
print(" UI : ERPNext Desk → Dispatch Settings")
|
||||||
|
print(" API : /api/resource/Dispatch Settings/Dispatch Settings")
|
||||||
|
|
||||||
|
|
||||||
|
create_dispatch_settings()
|
||||||
14
apps/dispatch/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Dispatch</title>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<meta name="msapplication-tap-highlight" content="no" />
|
||||||
|
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, viewport-fit=cover" />
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- quasar:entry-point -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
apps/dispatch/infra/.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# ERPNext
|
||||||
|
ERPNEXT_VERSION=v15.49.2
|
||||||
|
DB_ROOT_PASSWORD=admin
|
||||||
|
|
||||||
|
# PostgreSQL (address autocomplete)
|
||||||
|
PG_DB=dispatch
|
||||||
|
PG_USER=dispatch
|
||||||
|
PG_PASSWORD=dispatch
|
||||||
138
apps/dispatch/infra/docker-compose.erpnext.yaml
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# ERPNext Docker Compose — reference config for rebuilding infrastructure
|
||||||
|
# Based on frappe_docker: https://github.com/frappe/frappe_docker
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# cp .env.example .env (edit vars)
|
||||||
|
# docker compose -f docker-compose.erpnext.yaml up -d
|
||||||
|
#
|
||||||
|
# After ERPNext is running, deploy the dispatch PWA:
|
||||||
|
# cd ../dispatch-app && bash deploy.sh
|
||||||
|
|
||||||
|
x-customizable-image: &customizable_image
|
||||||
|
image: ${CUSTOM_IMAGE:-frappe/erpnext}:${CUSTOM_TAG:-v15.49.2}
|
||||||
|
pull_policy: ${PULL_POLICY:-always}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
x-depends-on-configurator: &depends_on_configurator
|
||||||
|
depends_on:
|
||||||
|
configurator:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
x-backend-defaults: &backend_defaults
|
||||||
|
<<: [*depends_on_configurator, *customizable_image]
|
||||||
|
volumes:
|
||||||
|
- sites:/home/frappe/frappe-bench/sites
|
||||||
|
|
||||||
|
services:
|
||||||
|
configurator:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
entrypoint: ["bash", "-c"]
|
||||||
|
command:
|
||||||
|
- >
|
||||||
|
ls -1 apps > sites/apps.txt;
|
||||||
|
bench set-config -g db_host $$DB_HOST;
|
||||||
|
bench set-config -gp db_port $$DB_PORT;
|
||||||
|
bench set-config -g redis_cache "redis://$$REDIS_CACHE";
|
||||||
|
bench set-config -g redis_queue "redis://$$REDIS_QUEUE";
|
||||||
|
bench set-config -g redis_socketio "redis://$$REDIS_QUEUE";
|
||||||
|
bench set-config -gp socketio_port $$SOCKETIO_PORT;
|
||||||
|
environment:
|
||||||
|
DB_HOST: ${DB_HOST:-db}
|
||||||
|
DB_PORT: ${DB_PORT:-3306}
|
||||||
|
REDIS_CACHE: ${REDIS_CACHE:-redis-cache:6379}
|
||||||
|
REDIS_QUEUE: ${REDIS_QUEUE:-redis-queue:6379}
|
||||||
|
SOCKETIO_PORT: 9000
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis-cache:
|
||||||
|
condition: service_started
|
||||||
|
redis-queue:
|
||||||
|
condition: service_started
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
backend:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
<<: *customizable_image
|
||||||
|
platform: linux/amd64
|
||||||
|
command: ["nginx-entrypoint.sh"]
|
||||||
|
environment:
|
||||||
|
BACKEND: backend:8000
|
||||||
|
SOCKETIO: websocket:9000
|
||||||
|
FRAPPE_SITE_NAME_HEADER: $$host
|
||||||
|
PROXY_READ_TIMEOUT: 120
|
||||||
|
CLIENT_MAX_BODY_SIZE: 50m
|
||||||
|
volumes:
|
||||||
|
- sites:/home/frappe/frappe-bench/sites
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- websocket
|
||||||
|
|
||||||
|
websocket:
|
||||||
|
<<: [*depends_on_configurator, *customizable_image]
|
||||||
|
platform: linux/amd64
|
||||||
|
command: ["node", "/home/frappe/frappe-bench/apps/frappe/socketio.js"]
|
||||||
|
volumes:
|
||||||
|
- sites:/home/frappe/frappe-bench/sites
|
||||||
|
|
||||||
|
queue-short:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
command: bench worker --queue short,default
|
||||||
|
|
||||||
|
queue-long:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
command: bench worker --queue long,default,short
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
command: bench schedule
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:10.11
|
||||||
|
platform: linux/amd64
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ['--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci', '--skip-character-set-client-handshake']
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-admin}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: mysqladmin ping -h localhost --password=$$MYSQL_ROOT_PASSWORD
|
||||||
|
interval: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis-cache:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis-queue:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# PostgreSQL for address autocomplete (rqa_addresses table)
|
||||||
|
postgres:
|
||||||
|
image: postgres:14-alpine
|
||||||
|
platform: linux/amd64
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${PG_DB:-dispatch}
|
||||||
|
POSTGRES_USER: ${PG_USER:-dispatch}
|
||||||
|
POSTGRES_PASSWORD: ${PG_PASSWORD:-dispatch}
|
||||||
|
volumes:
|
||||||
|
- pg-data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sites:
|
||||||
|
db-data:
|
||||||
|
pg-data:
|
||||||
10018
apps/dispatch/package-lock.json
generated
Normal file
37
apps/dispatch/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "dispatch-app",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Dispatch & Field Service app for ERPNext",
|
||||||
|
"productName": "Dispatch",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "quasar dev",
|
||||||
|
"build": "quasar build -m pwa",
|
||||||
|
"lint": "eslint --ext .js,.vue ./src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@quasar/extras": "^1.16.12",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"quasar": "^2.16.10",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@quasar/app-vite": "^1.10.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-vue": "^9.24.0",
|
||||||
|
"sass": "^1.72.0",
|
||||||
|
"workbox-build": "7.0.x",
|
||||||
|
"workbox-cacheable-response": "7.0.x",
|
||||||
|
"workbox-core": "7.0.x",
|
||||||
|
"workbox-expiration": "7.0.x",
|
||||||
|
"workbox-precaching": "7.0.x",
|
||||||
|
"workbox-routing": "7.0.x",
|
||||||
|
"workbox-strategies": "7.0.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20",
|
||||||
|
"npm": ">= 6.13.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/dispatch/public/icons/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/dispatch/public/icons/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/dispatch/public/icons/apple-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/dispatch/public/icons/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/dispatch/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/dispatch/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/dispatch/public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/dispatch/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/dispatch/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/dispatch/public/icons/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
apps/dispatch/public/icons/safari-pinned-tab.svg
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
75
apps/dispatch/quasar.config.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/* eslint-env node */
|
||||||
|
const { configure } = require('quasar/wrappers')
|
||||||
|
|
||||||
|
module.exports = configure(function (ctx) {
|
||||||
|
return {
|
||||||
|
boot: ['pinia'],
|
||||||
|
|
||||||
|
css: ['app.scss'],
|
||||||
|
|
||||||
|
extras: ['roboto-font', 'material-icons'],
|
||||||
|
|
||||||
|
build: {
|
||||||
|
target: {
|
||||||
|
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||||
|
node: 'node20',
|
||||||
|
},
|
||||||
|
vueRouterMode: 'hash',
|
||||||
|
// Base path = where the app is deployed under ERPNext
|
||||||
|
// Change this if you move the app to a different path
|
||||||
|
extendViteConf (viteConf) {
|
||||||
|
viteConf.base = process.env.DEPLOY_BASE || '/assets/dispatch-app/'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
devServer: {
|
||||||
|
open: false,
|
||||||
|
// Listen on all interfaces so the container port is reachable from the host
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 9000,
|
||||||
|
proxy: {
|
||||||
|
// Proxy ERPNext API calls to the frontend container
|
||||||
|
// host.docker.internal resolves to the Docker host on Mac / Windows
|
||||||
|
'/api': {
|
||||||
|
target: 'http://host.docker.internal:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
cookieDomainRewrite: 'localhost',
|
||||||
|
},
|
||||||
|
'/assets': {
|
||||||
|
target: 'http://host.docker.internal:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
framework: {
|
||||||
|
config: {},
|
||||||
|
// Only load what we actually use — add more as needed
|
||||||
|
plugins: ['Notify', 'Loading', 'LocalStorage'],
|
||||||
|
},
|
||||||
|
|
||||||
|
animations: [],
|
||||||
|
|
||||||
|
pwa: {
|
||||||
|
workboxMode: 'generateSW',
|
||||||
|
injectPwaMetaTags: true,
|
||||||
|
swFilename: 'sw.js',
|
||||||
|
manifestFilename: 'manifest.json',
|
||||||
|
useCredentialForManifestTag: false,
|
||||||
|
workboxOptions: {
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
},
|
||||||
|
extendManifestJson (json) {
|
||||||
|
json.name = 'Dispatch'
|
||||||
|
json.short_name = 'Dispatch'
|
||||||
|
json.description = 'Dispatch & Field Service'
|
||||||
|
json.display = 'standalone'
|
||||||
|
json.orientation = 'portrait'
|
||||||
|
json.background_color = '#ffffff'
|
||||||
|
json.theme_color = '#6366f1'
|
||||||
|
json.start_url = '.'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
88
apps/dispatch/scripts/fix_client_script.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
SCRIPT = """
|
||||||
|
frappe.ui.form.on('Dispatch Job', {
|
||||||
|
setup(frm) {
|
||||||
|
frm._addr_bound = false;
|
||||||
|
},
|
||||||
|
refresh(frm) {
|
||||||
|
if (frm._addr_bound) return;
|
||||||
|
frappe.run_serially([
|
||||||
|
() => frappe.timeout(1),
|
||||||
|
() => {
|
||||||
|
try { _bind_address_autocomplete(frm); }
|
||||||
|
catch(e) { console.warn('Address autocomplete deferred:', e.message); }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function _bind_address_autocomplete(frm) {
|
||||||
|
var ctrl = frm.fields_dict && frm.fields_dict.address;
|
||||||
|
if (!ctrl) return;
|
||||||
|
var input = ctrl.input || (ctrl.$input && ctrl.$input[0]);
|
||||||
|
if (!input) return;
|
||||||
|
if (frm._addr_bound) return;
|
||||||
|
frm._addr_bound = true;
|
||||||
|
|
||||||
|
var dropdown = document.createElement('div');
|
||||||
|
dropdown.style.cssText = 'position:absolute;z-index:1000;background:#fff;border:1px solid #d1d5db;border-radius:6px;max-height:250px;overflow-y:auto;width:100%;box-shadow:0 4px 12px rgba(0,0,0,0.15);display:none;';
|
||||||
|
input.parentElement.style.position = 'relative';
|
||||||
|
input.parentElement.appendChild(dropdown);
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
var q = this.value.trim();
|
||||||
|
if (q.length < 3) { dropdown.style.display = 'none'; return; }
|
||||||
|
timer = setTimeout(function() {
|
||||||
|
frappe.call({
|
||||||
|
method: 'search_address',
|
||||||
|
args: { q: q },
|
||||||
|
callback: function(r) {
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
var results = (r && r.results) || (r && r.message && r.message.results) || [];
|
||||||
|
if (!results.length) {
|
||||||
|
dropdown.innerHTML = '<div style="padding:8px 12px;color:#6b7280;font-size:12px">Aucun resultat</div>';
|
||||||
|
} else {
|
||||||
|
results.forEach(function(addr) {
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.style.cssText = 'padding:8px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid #f3f4f6;';
|
||||||
|
var html = '<strong>' + addr.address_full + '</strong>';
|
||||||
|
if (addr.ville) html += ' <span style="float:right;color:#6b7280;font-size:11px">' + addr.ville + '</span>';
|
||||||
|
item.innerHTML = html;
|
||||||
|
item.addEventListener('mousedown', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
frm.set_value('address', addr.address_full);
|
||||||
|
if (addr.latitude) frm.set_value('latitude', parseFloat(addr.latitude));
|
||||||
|
if (addr.longitude) frm.set_value('longitude', parseFloat(addr.longitude));
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
frm.dirty();
|
||||||
|
});
|
||||||
|
item.addEventListener('mouseenter', function() { this.style.background = '#f3f4f6'; });
|
||||||
|
item.addEventListener('mouseleave', function() { this.style.background = ''; });
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
setTimeout(function() { dropdown.style.display = 'none'; }, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
cs = frappe.get_doc('Client Script', 'Dispatch Job Address Autocomplete')
|
||||||
|
cs.enabled = 1
|
||||||
|
cs.script = SCRIPT
|
||||||
|
cs.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Client Script fixed and re-enabled')
|
||||||
|
frappe.destroy()
|
||||||
37
apps/dispatch/scripts/fix_search.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
words = query.strip().split()
|
||||||
|
conditions = []
|
||||||
|
params = {}
|
||||||
|
for i, w in enumerate(words):
|
||||||
|
key = "w" + str(i)
|
||||||
|
conditions.append("f_unaccent(address_full) ILIKE f_unaccent(%({})s)".format(key))
|
||||||
|
params[key] = "%" + w + "%"
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
results = frappe.db.sql(
|
||||||
|
"SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%%' THEN 0 WHEN code_postal LIKE 'J0S%%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10",
|
||||||
|
params, as_dict=True)
|
||||||
|
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Fixed: AND-based word search')
|
||||||
|
frappe.destroy()
|
||||||
42
apps/dispatch/scripts/fix_search2.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
words = query.strip().split()
|
||||||
|
conditions = []
|
||||||
|
params = {}
|
||||||
|
for i, w in enumerate(words):
|
||||||
|
key = "w" + str(i)
|
||||||
|
params[key] = "%" + w + "%"
|
||||||
|
conditions.append(
|
||||||
|
"(f_unaccent(address_full) ILIKE f_unaccent(%({k})s) "
|
||||||
|
"OR f_unaccent(rue) ILIKE f_unaccent(%({k})s) "
|
||||||
|
"OR f_unaccent(ville) ILIKE f_unaccent(%({k})s) "
|
||||||
|
"OR numero ILIKE %({k})s)".format(k=key)
|
||||||
|
)
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
results = frappe.db.sql(
|
||||||
|
"SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%%' THEN 0 WHEN code_postal LIKE 'J0S%%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10",
|
||||||
|
params, as_dict=True)
|
||||||
|
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Fixed: search across address_full, rue, ville, numero')
|
||||||
|
frappe.destroy()
|
||||||
43
apps/dispatch/scripts/fix_search3.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
words = query.strip().split()
|
||||||
|
conditions = []
|
||||||
|
params = {}
|
||||||
|
idx = 0
|
||||||
|
for w in words:
|
||||||
|
k = "w" + str(idx)
|
||||||
|
params[k] = "%" + w + "%"
|
||||||
|
conditions.append(
|
||||||
|
"(f_unaccent(address_full) ILIKE f_unaccent(%%(%s)s) "
|
||||||
|
"OR f_unaccent(rue) ILIKE f_unaccent(%%(%s)s) "
|
||||||
|
"OR f_unaccent(ville) ILIKE f_unaccent(%%(%s)s) "
|
||||||
|
"OR numero ILIKE %%(%s)s)" % (k, k, k, k)
|
||||||
|
)
|
||||||
|
idx = idx + 1
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
sql = ("SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%%%%' THEN 0 WHEN code_postal LIKE 'J0S%%%%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10")
|
||||||
|
|
||||||
|
results = frappe.db.sql(sql, params, as_dict=True)
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Fixed: no .format(), using % operator')
|
||||||
|
frappe.destroy()
|
||||||
40
apps/dispatch/scripts/fix_search_abbrev.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
q = query.strip().lower()
|
||||||
|
q = q.replace("ste-", "sainte-").replace("ste ", "sainte-")
|
||||||
|
q = q.replace("st-", "saint-").replace("st ", "saint-")
|
||||||
|
q = q.replace("boul ", "boulevard ").replace("boul.", "boulevard")
|
||||||
|
q = q.replace("ave ", "avenue ").replace("ave.", "avenue")
|
||||||
|
words = q.split()
|
||||||
|
conditions = []
|
||||||
|
for w in words:
|
||||||
|
escaped = frappe.db.escape("%" + w + "%")
|
||||||
|
conditions.append("search_text LIKE " + escaped)
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
sql = ("SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%' THEN 0 "
|
||||||
|
"WHEN code_postal LIKE 'J0S%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10")
|
||||||
|
|
||||||
|
results = frappe.db.sql(sql, as_dict=True)
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Updated: query normalizes ste->sainte, st->saint, boul->boulevard')
|
||||||
|
frappe.destroy()
|
||||||
35
apps/dispatch/scripts/fix_search_fast.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import frappe, os
|
||||||
|
os.chdir('/home/frappe/frappe-bench')
|
||||||
|
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
|
||||||
|
frappe.connect()
|
||||||
|
|
||||||
|
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
|
||||||
|
ss.script = """
|
||||||
|
query = frappe.form_dict.get("q", "")
|
||||||
|
if not query or len(query) < 3:
|
||||||
|
frappe.response["results"] = []
|
||||||
|
else:
|
||||||
|
words = query.strip().lower().split()
|
||||||
|
conditions = []
|
||||||
|
for w in words:
|
||||||
|
escaped = frappe.db.escape("%" + w + "%")
|
||||||
|
conditions.append("search_text LIKE " + escaped)
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
|
sql = ("SELECT address_full, ville, code_postal, latitude, longitude "
|
||||||
|
"FROM rqa_addresses "
|
||||||
|
"WHERE " + where + " "
|
||||||
|
"ORDER BY "
|
||||||
|
"CASE WHEN code_postal LIKE 'J0L%' THEN 0 "
|
||||||
|
"WHEN code_postal LIKE 'J0S%' THEN 1 ELSE 2 END, "
|
||||||
|
"length(address_full) "
|
||||||
|
"LIMIT 10")
|
||||||
|
|
||||||
|
results = frappe.db.sql(sql, as_dict=True)
|
||||||
|
frappe.response["results"] = results
|
||||||
|
"""
|
||||||
|
ss.save(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print('Updated: using frappe.db.escape, no params')
|
||||||
|
frappe.destroy()
|
||||||
52
apps/dispatch/scripts/import_rqa_addresses.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import RQA (Réseau Québécois d'Adresses) CSV into PostgreSQL civic_addresses table.
|
||||||
|
Handles the ~2.8GB CSV file with streaming/batched inserts.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 import_rqa_addresses.py /tmp/RQA_CSV/RQA.csv
|
||||||
|
|
||||||
|
Or from Docker:
|
||||||
|
docker cp import_rqa_addresses.py frappe_docker-db-1:/tmp/
|
||||||
|
docker exec frappe_docker-db-1 python3 /tmp/import_rqa_addresses.py /tmp/RQA.csv
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import io
|
||||||
|
|
||||||
|
DB = "_171cf82a99ac0463"
|
||||||
|
BATCH_SIZE = 10000
|
||||||
|
|
||||||
|
def get_csv_path():
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
return sys.argv[1]
|
||||||
|
# Auto-detect from unzipped location
|
||||||
|
for p in ['/tmp/RQA_CSV/RQA.csv', '/tmp/RQA.csv', '/tmp/RQA_CSV.csv']:
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
print("Usage: python3 import_rqa_addresses.py <path_to_csv>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
csv_path = get_csv_path()
|
||||||
|
print(f"Reading: {csv_path}")
|
||||||
|
|
||||||
|
# First peek at the header to understand columns
|
||||||
|
with open(csv_path, 'r', encoding='utf-8-sig', errors='replace') as f:
|
||||||
|
reader = csv.reader(f, delimiter=',')
|
||||||
|
header = next(reader)
|
||||||
|
print(f"Columns ({len(header)}): {header[:15]}...")
|
||||||
|
|
||||||
|
# Show first row
|
||||||
|
row = next(reader)
|
||||||
|
print(f"Sample row: {row[:15]}...")
|
||||||
|
|
||||||
|
print(f"\nHeader fields:")
|
||||||
|
for i, h in enumerate(header):
|
||||||
|
print(f" {i}: {h}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
30
apps/dispatch/src-pwa/custom-service-worker.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/* eslint-env serviceworker */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file (which will be your service worker)
|
||||||
|
* is picked up by the build system ONLY if
|
||||||
|
* quasar.config.js > pwa > workboxMode is set to "injectManifest"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { clientsClaim } from 'workbox-core'
|
||||||
|
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching'
|
||||||
|
import { registerRoute, NavigationRoute } from 'workbox-routing'
|
||||||
|
|
||||||
|
self.skipWaiting()
|
||||||
|
clientsClaim()
|
||||||
|
|
||||||
|
// Use with precache injection
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
|
|
||||||
|
cleanupOutdatedCaches()
|
||||||
|
|
||||||
|
// Non-SSR fallback to index.html
|
||||||
|
// Production SSR fallback to offline.html (except for dev)
|
||||||
|
if (process.env.MODE !== 'ssr' || process.env.PROD) {
|
||||||
|
registerRoute(
|
||||||
|
new NavigationRoute(
|
||||||
|
createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML),
|
||||||
|
{ denylist: [/sw\.js$/, /workbox-(.)*\.js$/] }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
32
apps/dispatch/src-pwa/manifest.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#027be3",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
apps/dispatch/src-pwa/pwa-flag.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
|
||||||
|
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
|
||||||
|
import "quasar/dist/types/feature-flag";
|
||||||
|
|
||||||
|
declare module "quasar/dist/types/feature-flag" {
|
||||||
|
interface QuasarFeatureFlags {
|
||||||
|
pwa: true;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/dispatch/src-pwa/register-service-worker.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { register } from 'register-service-worker'
|
||||||
|
|
||||||
|
// The ready(), registered(), cached(), updatefound() and updated()
|
||||||
|
// events passes a ServiceWorkerRegistration instance in their arguments.
|
||||||
|
// ServiceWorkerRegistration: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
|
||||||
|
|
||||||
|
register(process.env.SERVICE_WORKER_FILE, {
|
||||||
|
// The registrationOptions object will be passed as the second argument
|
||||||
|
// to ServiceWorkerContainer.register()
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
|
||||||
|
|
||||||
|
// registrationOptions: { scope: './' },
|
||||||
|
|
||||||
|
ready (/* registration */) {
|
||||||
|
// console.log('Service worker is active.')
|
||||||
|
},
|
||||||
|
|
||||||
|
registered (/* registration */) {
|
||||||
|
// console.log('Service worker has been registered.')
|
||||||
|
},
|
||||||
|
|
||||||
|
cached (/* registration */) {
|
||||||
|
// console.log('Content has been cached for offline use.')
|
||||||
|
},
|
||||||
|
|
||||||
|
updatefound (/* registration */) {
|
||||||
|
// console.log('New content is downloading.')
|
||||||
|
},
|
||||||
|
|
||||||
|
updated (/* registration */) {
|
||||||
|
// console.log('New content is available; please refresh.')
|
||||||
|
},
|
||||||
|
|
||||||
|
offline () {
|
||||||
|
// console.log('No internet connection found. App is running in offline mode.')
|
||||||
|
},
|
||||||
|
|
||||||
|
error (/* err */) {
|
||||||
|
// console.error('Error during service worker registration:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
11
apps/dispatch/src/App.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
onMounted(() => auth.checkSession())
|
||||||
|
</script>
|
||||||
44
apps/dispatch/src/api/auth.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// ── ERPNext API auth — service token + Authentik session guard ──────────────
|
||||||
|
// ERPNext API calls use a service token. User auth is via Authentik forwardAuth
|
||||||
|
// at the Traefik level. If the Authentik session expires mid-use, API calls
|
||||||
|
// get redirected (302) — we detect this and reload to trigger re-auth.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
// Service token injected at build time via VITE_ERP_TOKEN env var
|
||||||
|
// Fallback: read from window.__ERP_TOKEN__ (set by server-side injection)
|
||||||
|
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
|
||||||
|
|
||||||
|
export function authFetch (url, opts = {}) {
|
||||||
|
opts.headers = { ...opts.headers, Authorization: 'token ' + SERVICE_TOKEN }
|
||||||
|
opts.redirect = 'manual' // Don't follow redirects — detect Authentik 302
|
||||||
|
return fetch(url, opts).then(res => {
|
||||||
|
// If Traefik/Authentik redirects (session expired), reload page to re-auth
|
||||||
|
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) {
|
||||||
|
window.location.reload()
|
||||||
|
return new Response('{}', { status: 401 })
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCSRF () { return null }
|
||||||
|
export function invalidateCSRF () {}
|
||||||
|
|
||||||
|
export async function login () { window.location.reload() }
|
||||||
|
export async function logout () {
|
||||||
|
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLoggedUser () {
|
||||||
|
try {
|
||||||
|
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
|
||||||
|
headers: { Authorization: 'token ' + SERVICE_TOKEN },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.message || 'authenticated'
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return 'authenticated'
|
||||||
|
}
|
||||||
60
apps/dispatch/src/api/booking.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// ── Booking API — crée une demande client dans ERPNext ────────────────────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { getCSRF } from './auth'
|
||||||
|
|
||||||
|
const SLOT_LABELS = {
|
||||||
|
matin: 'Matin (8h00 – 12h00)',
|
||||||
|
aprem: 'Après-midi (12h00 – 17h00)',
|
||||||
|
soir: 'Soirée (17h00 – 20h00)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDescription (data) {
|
||||||
|
const dateLabel = { today: "Aujourd'hui", tomorrow: 'Demain' }[data.date] ?? data.date
|
||||||
|
return [
|
||||||
|
`SERVICE: ${data.service.label}`,
|
||||||
|
data.serviceNote ? `Détail: ${data.serviceNote}` : null,
|
||||||
|
`ADRESSE: ${data.address}`,
|
||||||
|
`DATE: ${dateLabel} — ${SLOT_LABELS[data.slot] ?? data.slot}`,
|
||||||
|
data.urgent ? '*** URGENT — intervention dans les 2h ***' : null,
|
||||||
|
'---',
|
||||||
|
`Client: ${data.contact.name}`,
|
||||||
|
`Téléphone: ${data.contact.phone}`,
|
||||||
|
data.contact.email ? `Courriel: ${data.contact.email}` : null,
|
||||||
|
data.contact.note ? `Note: ${data.contact.note}` : null,
|
||||||
|
].filter(Boolean).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function localRef () {
|
||||||
|
return 'DSP-' + Date.now().toString(36).toUpperCase().slice(-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBooking (data) {
|
||||||
|
const csrf = await getCSRF().catch(() => '')
|
||||||
|
|
||||||
|
// Try ERPNext Lead (CRM module — standard in ERPNext)
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/Lead`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({
|
||||||
|
lead_name: data.contact.name,
|
||||||
|
mobile_no: data.contact.phone,
|
||||||
|
email_id: data.contact.email || '',
|
||||||
|
source: 'Dispatch Booking',
|
||||||
|
notes: buildDescription(data),
|
||||||
|
status: 'Open',
|
||||||
|
lead_owner: '',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (r.ok && body.data?.name) return body.data.name
|
||||||
|
} catch (_) { /* fall through */ }
|
||||||
|
|
||||||
|
// Fallback: localStorage + generated ref
|
||||||
|
const ref = localRef()
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_bookings') || '[]')
|
||||||
|
list.push({ ref, ...data, created: new Date().toISOString() })
|
||||||
|
localStorage.setItem('dispatch_bookings', JSON.stringify(list))
|
||||||
|
return ref
|
||||||
|
}
|
||||||
74
apps/dispatch/src/api/contractor.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// ── Contractor API — inscrit un sous-traitant dans ERPNext ────────────────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { getCSRF } from './auth'
|
||||||
|
|
||||||
|
function buildNotes (data) {
|
||||||
|
const services = data.services
|
||||||
|
.map(s => ` • ${s.label}: ${s.rate}$ / ${s.rateType === 'hourly' ? 'heure' : 'forfait'}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const days = data.availability.days
|
||||||
|
.map(d => ({ mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }[d]))
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
return [
|
||||||
|
`SERVICES OFFERTS:`,
|
||||||
|
services,
|
||||||
|
``,
|
||||||
|
`ZONE: ${data.availability.city} — rayon ${data.availability.radius}`,
|
||||||
|
`DISPONIBILITÉ: ${days}`,
|
||||||
|
data.availability.urgent ? 'Disponible pour urgences' : '',
|
||||||
|
``,
|
||||||
|
data.profile.license ? `Licence/RBQ: ${data.profile.license}` : '',
|
||||||
|
data.profile.company ? `Entreprise: ${data.profile.company}` : '',
|
||||||
|
].filter(s => s !== undefined).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function localRef () {
|
||||||
|
return 'TECH-' + Date.now().toString(36).toUpperCase().slice(-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerContractor (data) {
|
||||||
|
const csrf = await getCSRF().catch(() => '')
|
||||||
|
|
||||||
|
// Try ERPNext Supplier (standard ERPNext)
|
||||||
|
try {
|
||||||
|
const supplierName = data.profile.company
|
||||||
|
|| `${data.profile.firstname} ${data.profile.lastname}`
|
||||||
|
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/Supplier`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({
|
||||||
|
supplier_name: supplierName,
|
||||||
|
supplier_type: 'Individual',
|
||||||
|
supplier_group: 'Services',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (r.ok && body.data?.name) {
|
||||||
|
// Try to create a Contact linked to the supplier
|
||||||
|
await fetch(`${BASE_URL}/api/resource/Contact`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: data.profile.firstname,
|
||||||
|
last_name: data.profile.lastname,
|
||||||
|
email_ids: [{ email_id: data.profile.email, is_primary: 1 }],
|
||||||
|
phone_nos: [{ phone: data.profile.phone, is_primary_phone: 1 }],
|
||||||
|
links: [{ link_doctype: 'Supplier', link_name: body.data.name }],
|
||||||
|
}),
|
||||||
|
}).catch(() => {})
|
||||||
|
return body.data.name
|
||||||
|
}
|
||||||
|
} catch (_) { /* fall through */ }
|
||||||
|
|
||||||
|
// Fallback: localStorage + generated ref
|
||||||
|
const ref = localRef()
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_contractors') || '[]')
|
||||||
|
list.push({ ref, ...data, created: new Date().toISOString(), status: 'pending_review' })
|
||||||
|
localStorage.setItem('dispatch_contractors', JSON.stringify(list))
|
||||||
|
return ref
|
||||||
|
}
|
||||||
116
apps/dispatch/src/api/dispatch.js
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
// ── ERPNext Dispatch resource calls ─────────────────────────────────────────
|
||||||
|
// All ERPNext fetch() calls live here.
|
||||||
|
// Swap BASE_URL in config/erpnext.js to change the target server.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { authFetch } from './auth'
|
||||||
|
|
||||||
|
async function apiGet (path) {
|
||||||
|
const res = await authFetch(BASE_URL + path)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.exc) throw new Error(data.exc)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPut (doctype, name, body) {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE_URL}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!res.ok) console.error(`[API] PUT ${doctype}/${name} failed:`, res.status, await res.text().catch(() => ''))
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.exc) throw new Error(data.exc)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTechnicians () {
|
||||||
|
const list = await apiGet('/api/resource/Dispatch%20Technician?fields=["name"]&limit=100')
|
||||||
|
const names = (list.data || []).map(t => t.name)
|
||||||
|
if (!names.length) return []
|
||||||
|
const docs = await Promise.all(
|
||||||
|
names.map(n => apiGet(`/api/resource/Dispatch%20Technician/${encodeURIComponent(n)}`).then(d => d.data))
|
||||||
|
)
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all jobs with child tables (assistants)
|
||||||
|
export async function fetchJobs (filters = null) {
|
||||||
|
// Step 1: get job names from list endpoint
|
||||||
|
let url = '/api/resource/Dispatch%20Job?fields=["name"]&limit=200'
|
||||||
|
if (filters) url += '&filters=' + encodeURIComponent(JSON.stringify(filters))
|
||||||
|
const list = await apiGet(url)
|
||||||
|
const names = (list.data || []).map(j => j.name)
|
||||||
|
if (!names.length) return []
|
||||||
|
// Step 2: fetch each doc individually (includes child tables)
|
||||||
|
const docs = await Promise.all(
|
||||||
|
names.map(n => apiGet(`/api/resource/Dispatch%20Job/${encodeURIComponent(n)}`).then(d => d.data))
|
||||||
|
)
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateJob (name, payload) {
|
||||||
|
return apiPut('Dispatch Job', name, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createJob (payload) {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE_URL}/api/resource/Dispatch%20Job`,
|
||||||
|
{
|
||||||
|
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 updateTech (name, payload) {
|
||||||
|
return apiPut('Dispatch Technician', name, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTech (payload) {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE_URL}/api/resource/Dispatch%20Technician`,
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) },
|
||||||
|
)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.exc) throw new Error(data.exc)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTech (name) {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE_URL}/api/resource/Dispatch%20Technician/${encodeURIComponent(name)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
const msg = data._server_messages ? JSON.parse(JSON.parse(data._server_messages)[0]).message : data.exception || 'Delete failed'
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTags () {
|
||||||
|
const data = await apiGet('/api/resource/Dispatch%20Tag?fields=["name","label","color","category"]&limit=200')
|
||||||
|
return data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTag (label, category = 'Custom', color = '#6b7280') {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE_URL}/api/resource/Dispatch%20Tag`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label, category, color }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.exc) throw new Error(data.exc)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
273
apps/dispatch/src/api/service-request.js
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
/**
|
||||||
|
* API — ServiceRequest, ServiceBid, EquipmentInstall
|
||||||
|
*
|
||||||
|
* Tries Frappe custom doctypes first, falls back to Lead + localStorage
|
||||||
|
* so the app works before the backend doctypes are created.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = ''
|
||||||
|
|
||||||
|
async function getCSRF () {
|
||||||
|
const m = document.cookie.match(/csrftoken=([^;]+)/)
|
||||||
|
if (m) return m[1]
|
||||||
|
const r = await fetch('/api/method/frappe.auth.get_csrf_token', { credentials: 'include' })
|
||||||
|
const d = await r.json().catch(() => ({}))
|
||||||
|
return d.csrf_token || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frappePOST (doctype, data) {
|
||||||
|
const csrf = await getCSRF().catch(() => '')
|
||||||
|
const r = await fetch(`${BASE}/api/resource/${encodeURIComponent(doctype)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const body = await r.json()
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frappePUT (doctype, name, data) {
|
||||||
|
const csrf = await getCSRF().catch(() => '')
|
||||||
|
const r = await fetch(`${BASE}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const body = await r.json()
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frappeGET (doctype, filters = {}, fields = ['name']) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
fields: JSON.stringify(fields),
|
||||||
|
filters: JSON.stringify(filters),
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
const r = await fetch(`${BASE}/api/resource/${encodeURIComponent(doctype)}?${params}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const body = await r.json()
|
||||||
|
return body.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// ServiceRequest
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createServiceRequest (data) {
|
||||||
|
/**
|
||||||
|
* data = {
|
||||||
|
* service_type: 'internet' | 'tv' | 'telephone' | 'multi',
|
||||||
|
* problem_type: string,
|
||||||
|
* description: string,
|
||||||
|
* address: string,
|
||||||
|
* coordinates: [lng, lat],
|
||||||
|
* preferred_dates: [{ date, time_slot, time_slots[] }, ...], // up to 3
|
||||||
|
* contact: { name, phone, email },
|
||||||
|
* urgency: 'normal' | 'urgent',
|
||||||
|
* budget: { id, label, min, max } | null,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
const ref = 'SR-' + Date.now().toString(36).toUpperCase().slice(-6)
|
||||||
|
|
||||||
|
// Try Frappe ServiceRequest doctype
|
||||||
|
try {
|
||||||
|
const doc = await frappePOST('Service Request', {
|
||||||
|
customer_name: data.contact.name,
|
||||||
|
phone: data.contact.phone,
|
||||||
|
email: data.contact.email,
|
||||||
|
service_type: data.service_type,
|
||||||
|
problem_type: data.problem_type,
|
||||||
|
description: data.description,
|
||||||
|
address: data.address,
|
||||||
|
lng: data.coordinates?.[0] || 0,
|
||||||
|
lat: data.coordinates?.[1] || 0,
|
||||||
|
preferred_date_1: data.preferred_dates[0]?.date || '',
|
||||||
|
time_slot_1: data.preferred_dates[0]?.time_slot || '',
|
||||||
|
preferred_date_2: data.preferred_dates[1]?.date || '',
|
||||||
|
time_slot_2: data.preferred_dates[1]?.time_slot || '',
|
||||||
|
preferred_date_3: data.preferred_dates[2]?.date || '',
|
||||||
|
time_slot_3: data.preferred_dates[2]?.time_slot || '',
|
||||||
|
urgency: data.urgency || 'normal',
|
||||||
|
budget_label: data.budget?.label || '',
|
||||||
|
budget_min: data.budget?.min || 0,
|
||||||
|
budget_max: data.budget?.max || 0,
|
||||||
|
status: 'New',
|
||||||
|
})
|
||||||
|
return { ref: doc.name, source: 'frappe' }
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Fallback: create as Frappe Lead + HD Ticket
|
||||||
|
try {
|
||||||
|
const notes = buildNotes(data)
|
||||||
|
const doc = await frappePOST('Lead', {
|
||||||
|
lead_name: data.contact.name,
|
||||||
|
mobile_no: data.contact.phone,
|
||||||
|
email_id: data.contact.email || '',
|
||||||
|
source: 'Dispatch Booking',
|
||||||
|
lead_owner: '',
|
||||||
|
status: 'Open',
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
return { ref: doc.name, source: 'lead' }
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Final fallback: localStorage
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
|
||||||
|
list.push({ ref, ...data, lng: data.coordinates?.[0] || 0, lat: data.coordinates?.[1] || 0, budget_label: data.budget?.label || '', created: new Date().toISOString(), status: 'new' })
|
||||||
|
localStorage.setItem('dispatch_service_requests', JSON.stringify(list))
|
||||||
|
return { ref, source: 'local' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNotes (data) {
|
||||||
|
const dates = data.preferred_dates
|
||||||
|
.filter(d => d.date)
|
||||||
|
.map((d, i) => ` Date ${i + 1}: ${d.date} — ${d.time_slot}`)
|
||||||
|
.join('\n')
|
||||||
|
return [
|
||||||
|
`SERVICE: ${data.service_type?.toUpperCase()}`,
|
||||||
|
`PROBLÈME: ${data.problem_type}`,
|
||||||
|
`DESCRIPTION: ${data.description}`,
|
||||||
|
`ADRESSE: ${data.address}`,
|
||||||
|
`URGENCE: ${data.urgency}`,
|
||||||
|
'',
|
||||||
|
'DATES PRÉFÉRÉES:',
|
||||||
|
dates,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServiceRequests (status = null) {
|
||||||
|
try {
|
||||||
|
const filters = status ? { status } : {}
|
||||||
|
return await frappeGET('Service Request', filters, [
|
||||||
|
'name', 'customer_name', 'phone', 'service_type', 'problem_type',
|
||||||
|
'description', 'address', 'status', 'urgency',
|
||||||
|
'preferred_date_1', 'time_slot_1',
|
||||||
|
'preferred_date_2', 'time_slot_2',
|
||||||
|
'preferred_date_3', 'time_slot_3',
|
||||||
|
'confirmed_date', 'creation',
|
||||||
|
'budget_label', 'budget_min', 'budget_max',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
return JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServiceRequestStatus (name, status, confirmedDate = null) {
|
||||||
|
try {
|
||||||
|
const data = {}
|
||||||
|
if (status) data.status = status
|
||||||
|
if (confirmedDate) data.confirmed_date = confirmedDate
|
||||||
|
if (Object.keys(data).length === 0) return
|
||||||
|
return await frappePUT('Service Request', name, data)
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
|
||||||
|
const item = list.find(r => r.ref === name || r.name === name)
|
||||||
|
if (item) {
|
||||||
|
if (status) item.status = status
|
||||||
|
if (confirmedDate) item.confirmed_date = confirmedDate
|
||||||
|
}
|
||||||
|
localStorage.setItem('dispatch_service_requests', JSON.stringify(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// ServiceBid (tech bids on a date)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createServiceBid (data) {
|
||||||
|
/**
|
||||||
|
* data = { request, technician, proposed_date, time_slot, estimated_duration, notes, price }
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
return await frappePOST('Service Bid', { ...data, status: 'Pending' })
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_bids') || '[]')
|
||||||
|
const bid = { ref: 'BID-' + Date.now().toString(36).toUpperCase().slice(-6), ...data, status: 'pending', created: new Date().toISOString() }
|
||||||
|
list.push(bid)
|
||||||
|
localStorage.setItem('dispatch_service_bids', JSON.stringify(list))
|
||||||
|
return bid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBidsForRequest (requestName) {
|
||||||
|
try {
|
||||||
|
return await frappeGET('Service Bid', { request: requestName }, [
|
||||||
|
'name', 'technician', 'proposed_date', 'time_slot',
|
||||||
|
'estimated_duration', 'notes', 'status', 'creation',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_bids') || '[]')
|
||||||
|
return list.filter(b => b.request === requestName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBidsForTech (techName) {
|
||||||
|
try {
|
||||||
|
return await frappeGET('Service Bid', { technician: techName }, [
|
||||||
|
'name', 'request', 'proposed_date', 'time_slot', 'status', 'creation',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_bids') || '[]')
|
||||||
|
return list.filter(b => b.technician === techName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOpenRequests () {
|
||||||
|
try {
|
||||||
|
return await frappeGET('Service Request', { status: ['in', ['New', 'Bidding']] }, [
|
||||||
|
'name', 'customer_name', 'service_type', 'problem_type', 'description',
|
||||||
|
'address', 'lng', 'lat', 'urgency', 'preferred_date_1', 'time_slot_1',
|
||||||
|
'preferred_date_2', 'time_slot_2', 'preferred_date_3', 'time_slot_3',
|
||||||
|
'creation', 'budget_label', 'budget_min', 'budget_max',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
|
||||||
|
return list.filter(r => ['new', 'bidding'].includes(r.status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptBid (bidName, requestName, confirmedDate) {
|
||||||
|
try {
|
||||||
|
await frappePUT('Service Bid', bidName, { status: 'Accepted' })
|
||||||
|
await frappePUT('Service Request', requestName, { status: 'Confirmed', confirmed_date: confirmedDate })
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// EquipmentInstall (barcode scan on site)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createEquipmentInstall (data) {
|
||||||
|
/**
|
||||||
|
* data = { request, barcode, equipment_type, brand, model, notes, photo_base64 }
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
return await frappePOST('Equipment Install', {
|
||||||
|
...data,
|
||||||
|
installation_date: new Date().toISOString().split('T')[0],
|
||||||
|
})
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_equipment') || '[]')
|
||||||
|
const item = { ref: 'EQ-' + Date.now().toString(36).toUpperCase().slice(-6), ...data, created: new Date().toISOString() }
|
||||||
|
list.push(item)
|
||||||
|
localStorage.setItem('dispatch_equipment', JSON.stringify(list))
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEquipmentForRequest (requestName) {
|
||||||
|
try {
|
||||||
|
return await frappeGET('Equipment Install', { request: requestName }, [
|
||||||
|
'name', 'barcode', 'equipment_type', 'brand', 'model', 'notes', 'installation_date',
|
||||||
|
])
|
||||||
|
} catch (_) {
|
||||||
|
const list = JSON.parse(localStorage.getItem('dispatch_equipment') || '[]')
|
||||||
|
return list.filter(e => e.request === requestName)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/dispatch/src/api/settings.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// ── Dispatch Settings — lecture/écriture du DocType Single ERPNext ───────────
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { getCSRF } from './auth'
|
||||||
|
|
||||||
|
const DOCTYPE = 'Dispatch Settings'
|
||||||
|
const NAME = 'Dispatch Settings'
|
||||||
|
|
||||||
|
function isDocTypeError (body) {
|
||||||
|
const s = JSON.stringify(body)
|
||||||
|
return s.includes('dispatch_settings') || s.includes('DoesNotExist') || s.includes('No module named')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSettings () {
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/${DOCTYPE}/${NAME}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (r.status === 404 || isDocTypeError(body)) throw new Error('DOCTYPE_NOT_FOUND')
|
||||||
|
throw new Error(`Erreur HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
const body = await r.json()
|
||||||
|
// Frappe peut retourner 200 avec une exception dans le corps
|
||||||
|
if (body.exc_type || body.exception) {
|
||||||
|
if (isDocTypeError(body)) throw new Error('DOCTYPE_NOT_FOUND')
|
||||||
|
throw new Error(body.exc_type || 'Erreur Frappe')
|
||||||
|
}
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings (payload) {
|
||||||
|
const csrf = await getCSRF()
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/${DOCTYPE}/${NAME}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf || '' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok || body.exc_type || body.exception) {
|
||||||
|
if (isDocTypeError(body)) throw new Error('DOCTYPE_NOT_FOUND')
|
||||||
|
throw new Error(body._error_message || body.exc_type || `Erreur HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Création du DocType via API (bouton Initialiser dans l'admin) ─────────────
|
||||||
|
const DOCTYPE_FIELDS = [
|
||||||
|
{ fieldname: 'erp_section', fieldtype: 'Section Break', label: 'ERPNext / Frappe' },
|
||||||
|
{ fieldname: 'erp_url', fieldtype: 'Data', label: 'URL du serveur', default: 'http://localhost:8080' },
|
||||||
|
{ fieldname: 'erp_api_key', fieldtype: 'Data', label: 'API Key' },
|
||||||
|
{ fieldname: 'erp_api_secret', fieldtype: 'Password', label: 'API Secret' },
|
||||||
|
{ fieldname: 'mapbox_section', fieldtype: 'Section Break', label: 'Mapbox' },
|
||||||
|
{ fieldname: 'mapbox_token', fieldtype: 'Data', label: 'Token public (pk_)' },
|
||||||
|
{ fieldname: 'twilio_section', fieldtype: 'Section Break', label: 'Twilio — SMS' },
|
||||||
|
{ fieldname: 'twilio_account_sid', fieldtype: 'Data', label: 'Account SID' },
|
||||||
|
{ fieldname: 'twilio_auth_token', fieldtype: 'Password', label: 'Auth Token' },
|
||||||
|
{ fieldname: 'twilio_from_number', fieldtype: 'Data', label: 'Numéro expéditeur' },
|
||||||
|
{ fieldname: 'stripe_section', fieldtype: 'Section Break', label: 'Stripe — Paiements' },
|
||||||
|
{ fieldname: 'stripe_mode', fieldtype: 'Select', label: 'Mode', options: 'test\nlive', default: 'test' },
|
||||||
|
{ fieldname: 'stripe_publishable_key', fieldtype: 'Data', label: 'Clé publique (pk_)' },
|
||||||
|
{ fieldname: 'stripe_secret_key', fieldtype: 'Password', label: 'Clé secrète (sk_)' },
|
||||||
|
{ fieldname: 'stripe_webhook_secret',fieldtype: 'Password', label: 'Webhook Secret (whsec_)' },
|
||||||
|
{ fieldname: 'n8n_section', fieldtype: 'Section Break', label: 'n8n — Automatisation' },
|
||||||
|
{ fieldname: 'n8n_url', fieldtype: 'Data', label: 'URL n8n', default: 'http://localhost:5678' },
|
||||||
|
{ fieldname: 'n8n_api_key', fieldtype: 'Password', label: 'API Key n8n' },
|
||||||
|
{ fieldname: 'n8n_webhook_base', fieldtype: 'Data', label: 'Base URL webhooks', default: 'http://localhost:5678/webhook' },
|
||||||
|
{ fieldname: 'sms_section', fieldtype: 'Section Break', label: 'Templates SMS' },
|
||||||
|
{ fieldname: 'sms_enroute', fieldtype: 'Text', label: 'Technicien en route',
|
||||||
|
default: 'Bonjour {client_name}, votre technicien {tech_name} est en route et arrivera dans environ {eta} minutes. Réf: {job_id}' },
|
||||||
|
{ fieldname: 'sms_completed', fieldtype: 'Text', label: 'Service complété',
|
||||||
|
default: 'Bonjour {client_name}, votre service ({job_id}) a été complété avec succès. Merci de votre confiance !' },
|
||||||
|
{ fieldname: 'sms_assigned', fieldtype: 'Text', label: 'Job assigné (technicien)',
|
||||||
|
default: 'Nouveau job assigné : {job_id} — {client_name}, {address}. Durée estimée : {duration}h.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function createDocType () {
|
||||||
|
const csrf = await getCSRF()
|
||||||
|
const r = await fetch(`${BASE_URL}/api/resource/DocType`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf || '' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: DOCTYPE, module: 'Core', custom: 1, is_single: 1, track_changes: 0,
|
||||||
|
fields: DOCTYPE_FIELDS,
|
||||||
|
permissions: [
|
||||||
|
{ role: 'System Manager', read: 1, write: 1, create: 1 },
|
||||||
|
{ role: 'Administrator', read: 1, write: 1, create: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok || body.exc_type) {
|
||||||
|
throw new Error(body._error_message || body.exc_type || `Erreur HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
94
apps/dispatch/src/api/traccar.js
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
// ── Traccar GPS API ──────────────────────────────────────────────────────────
|
||||||
|
// Polls Traccar for real-time device positions.
|
||||||
|
// Auth: session cookie via POST /api/session
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Use proxy on same origin to avoid mixed content (HTTPS → HTTP)
|
||||||
|
const TRACCAR_URL = window.location.hostname === 'localhost'
|
||||||
|
? 'http://tracker.targointernet.com:8082'
|
||||||
|
: window.location.origin + '/traccar'
|
||||||
|
const TRACCAR_USER = 'louis@targo.ca'
|
||||||
|
const TRACCAR_PASS = 'targo2026'
|
||||||
|
|
||||||
|
let _devices = []
|
||||||
|
|
||||||
|
// Use Basic auth — works through proxy without cookies
|
||||||
|
function authOpts () {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Basic ' + btoa(TRACCAR_USER + ':' + TRACCAR_PASS),
|
||||||
|
Accept: 'application/json',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Devices ──────────────────────────────────────────────────────────────────
|
||||||
|
export async function fetchDevices () {
|
||||||
|
try {
|
||||||
|
const res = await fetch(TRACCAR_URL + '/api/devices?all=true', authOpts())
|
||||||
|
if (res.ok) {
|
||||||
|
_devices = await res.json()
|
||||||
|
return _devices
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return _devices
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Positions ────────────────────────────────────────────────────────────────
|
||||||
|
// Traccar API only supports ONE deviceId per request — fetch in parallel
|
||||||
|
export async function fetchPositions (deviceIds = null) {
|
||||||
|
if (!deviceIds || !deviceIds.length) return []
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
deviceIds.map(id =>
|
||||||
|
fetch(TRACCAR_URL + '/api/positions?deviceId=' + id, authOpts())
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results.flatMap(r => r.status === 'fulfilled' ? r.value : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get position for a specific device ───────────────────────────────────────
|
||||||
|
export async function fetchDevicePosition (deviceId) {
|
||||||
|
const positions = await fetchPositions([deviceId])
|
||||||
|
return positions[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get all positions mapped by deviceId ─────────────────────────────────────
|
||||||
|
export async function fetchAllPositions () {
|
||||||
|
// Get devices we care about (online + offline with recent position)
|
||||||
|
if (!_devices.length) await fetchDevices()
|
||||||
|
const deviceIds = _devices.filter(d => d.positionId).map(d => d.id)
|
||||||
|
if (!deviceIds.length) return {}
|
||||||
|
|
||||||
|
const positions = await fetchPositions(deviceIds)
|
||||||
|
const map = {}
|
||||||
|
positions.forEach(p => { map[p.deviceId] = p })
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility: match device to tech by uniqueId or name ────────────────────────
|
||||||
|
export function matchDeviceToTech (devices, techs) {
|
||||||
|
const matched = []
|
||||||
|
for (const tech of techs) {
|
||||||
|
const traccarId = tech.traccarDeviceId
|
||||||
|
if (!traccarId) continue
|
||||||
|
const device = devices.find(d => d.id === parseInt(traccarId) || d.uniqueId === traccarId)
|
||||||
|
if (device) matched.push({ tech, device })
|
||||||
|
}
|
||||||
|
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 }
|
||||||
5
apps/dispatch/src/boot/pinia.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
export default ({ app }) => {
|
||||||
|
app.use(createPinia())
|
||||||
|
}
|
||||||
137
apps/dispatch/src/components/TagInput.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, default: () => [] }, // current tag labels
|
||||||
|
allTags: { type: Array, default: () => [] }, // { label, color, category }
|
||||||
|
getColor: { type: Function, default: () => '#6b7280' },
|
||||||
|
placeholder:{ type: String, default: 'Ajouter un tag…' },
|
||||||
|
canCreate: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'create'])
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const focused = ref(false)
|
||||||
|
const inputEl = ref(null)
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = query.value.trim().toLowerCase()
|
||||||
|
if (!q) return props.allTags.filter(t => !props.modelValue.includes(t.label)).slice(0, 12)
|
||||||
|
return props.allTags
|
||||||
|
.filter(t => !props.modelValue.includes(t.label) && t.label.toLowerCase().includes(q))
|
||||||
|
.slice(0, 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showCreate = computed(() => {
|
||||||
|
if (!props.canCreate) return false
|
||||||
|
const q = query.value.trim()
|
||||||
|
if (!q || q.length < 2) return false
|
||||||
|
return !props.allTags.some(t => t.label.toLowerCase() === q.toLowerCase())
|
||||||
|
})
|
||||||
|
|
||||||
|
function addTag (label) {
|
||||||
|
if (!label || props.modelValue.includes(label)) return
|
||||||
|
emit('update:modelValue', [...props.modelValue, label])
|
||||||
|
query.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag (label) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(t => t !== label))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAndAdd () {
|
||||||
|
const label = query.value.trim()
|
||||||
|
if (!label) return
|
||||||
|
emit('create', label)
|
||||||
|
addTag(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur () {
|
||||||
|
setTimeout(() => { focused.value = false }, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown (e) {
|
||||||
|
if (e.key === 'Enter' && query.value.trim()) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (filtered.value.length) addTag(filtered.value[0].label)
|
||||||
|
else if (showCreate.value) createAndAdd()
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' && !query.value && props.modelValue.length) {
|
||||||
|
removeTag(props.modelValue[props.modelValue.length - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ti-wrap" :class="{ 'ti-focused': focused }">
|
||||||
|
<!-- Existing tags as chips -->
|
||||||
|
<span v-for="t in modelValue" :key="t" class="ti-chip" :style="'background:'+getColor(t)">
|
||||||
|
{{ t }}
|
||||||
|
<button class="ti-chip-rm" @click.stop="removeTag(t)">×</button>
|
||||||
|
</span>
|
||||||
|
<!-- Input -->
|
||||||
|
<input ref="inputEl" class="ti-input" type="text"
|
||||||
|
v-model="query" :placeholder="modelValue.length ? '' : placeholder"
|
||||||
|
@focus="focused=true" @blur="onBlur" @keydown="onKeydown" />
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<div v-if="focused && (filtered.length || showCreate)" class="ti-dropdown">
|
||||||
|
<div v-for="t in filtered" :key="t.label" class="ti-option" @mousedown.prevent="addTag(t.label)">
|
||||||
|
<span class="ti-opt-dot" :style="'background:'+getColor(t.label)"></span>
|
||||||
|
<span class="ti-opt-label">{{ t.label }}</span>
|
||||||
|
<span class="ti-opt-cat">{{ t.category }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCreate" class="ti-option ti-option-create" @mousedown.prevent="createAndAdd">
|
||||||
|
<span class="ti-create-plus">+</span>
|
||||||
|
<span>Créer « <strong>{{ query.trim() }}</strong> »</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ti-wrap {
|
||||||
|
display:flex; flex-wrap:wrap; gap:3px; align-items:center;
|
||||||
|
background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px;
|
||||||
|
padding:3px 6px; min-height:28px; position:relative; cursor:text;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
.ti-wrap.ti-focused { border-color:rgba(99,102,241,0.4); }
|
||||||
|
.ti-chip {
|
||||||
|
display:inline-flex; align-items:center; gap:2px;
|
||||||
|
font-size:0.58rem; font-weight:600; color:#fff;
|
||||||
|
padding:1px 6px; border-radius:10px; white-space:nowrap;
|
||||||
|
}
|
||||||
|
.ti-chip-rm {
|
||||||
|
background:none; border:none; color:rgba(255,255,255,0.6); cursor:pointer;
|
||||||
|
font-size:0.7rem; padding:0 1px; margin-left:1px; line-height:1;
|
||||||
|
}
|
||||||
|
.ti-chip-rm:hover { color:#fff; }
|
||||||
|
.ti-input {
|
||||||
|
flex:1; min-width:60px; background:none; border:none; outline:none;
|
||||||
|
color:#e2e4ef; font-size:0.72rem; padding:2px 0;
|
||||||
|
}
|
||||||
|
.ti-input::placeholder { color:#7b80a0; }
|
||||||
|
.ti-dropdown {
|
||||||
|
position:absolute; top:100%; left:0; right:0; z-index:50;
|
||||||
|
background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px;
|
||||||
|
max-height:180px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.45);
|
||||||
|
margin-top:2px;
|
||||||
|
}
|
||||||
|
.ti-dropdown::-webkit-scrollbar { width:3px; }
|
||||||
|
.ti-dropdown::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
|
||||||
|
.ti-option {
|
||||||
|
display:flex; align-items:center; gap:6px;
|
||||||
|
padding:5px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef;
|
||||||
|
transition:background 0.1s;
|
||||||
|
}
|
||||||
|
.ti-option:hover { background:rgba(99,102,241,0.12); }
|
||||||
|
.ti-opt-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||||||
|
.ti-opt-label { flex:1; }
|
||||||
|
.ti-opt-cat { font-size:0.55rem; color:#7b80a0; }
|
||||||
|
.ti-option-create { color:#6366f1; font-weight:600; border-top:1px solid rgba(255,255,255,0.06); }
|
||||||
|
.ti-create-plus {
|
||||||
|
width:18px; height:18px; border-radius:50%; background:rgba(99,102,241,0.2);
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:0.75rem; font-weight:800; color:#6366f1; flex-shrink:0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
140
apps/dispatch/src/composables/useAutoDispatch.js
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// ── Auto-dispatch composable: autoDistribute + optimizeRoute ─────────────────
|
||||||
|
import { localDateStr } from './useHelpers'
|
||||||
|
import { updateJob } from 'src/api/dispatch'
|
||||||
|
|
||||||
|
export function useAutoDispatch (deps) {
|
||||||
|
const { store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs, bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes } = deps
|
||||||
|
|
||||||
|
async function autoDistribute () {
|
||||||
|
const techs = filteredResources.value
|
||||||
|
if (!techs.length) return
|
||||||
|
const today = localDateStr(new Date())
|
||||||
|
let pool
|
||||||
|
if (bottomSelected.value.size) {
|
||||||
|
pool = [...bottomSelected.value].map(id => store.jobs.find(j => j.id === id)).filter(Boolean)
|
||||||
|
} else {
|
||||||
|
pool = unscheduledJobs.value.filter(j => !j.scheduledDate || j.scheduledDate === today)
|
||||||
|
}
|
||||||
|
if (!pool.length) return
|
||||||
|
// Jobs with coords get proximity-based assignment, jobs without get load-balanced only
|
||||||
|
const withCoords = pool.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0))
|
||||||
|
const noCoords = pool.filter(j => !j.coords || (j.coords[0] === 0 && j.coords[1] === 0))
|
||||||
|
const unassigned = [...withCoords, ...noCoords]
|
||||||
|
if (!unassigned.length) return
|
||||||
|
|
||||||
|
const prevQueues = {}
|
||||||
|
techs.forEach(t => { prevQueues[t.id] = [...t.queue] })
|
||||||
|
const prevAssignments = unassigned.map(j => ({ jobId: j.id, techId: j.assignedTech, scheduledDate: j.scheduledDate }))
|
||||||
|
|
||||||
|
function techLoadForDay (tech, dayStr) {
|
||||||
|
return tech.queue.filter(j => getJobDate(j.id) === dayStr).reduce((s, j) => s + (parseFloat(j.duration) || 1), 0)
|
||||||
|
}
|
||||||
|
function dist (a, b) {
|
||||||
|
if (!a || !b) return 999
|
||||||
|
const dx = (a[0] - b[0]) * 80, dy = (a[1] - b[1]) * 111
|
||||||
|
return Math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
function techLastPosForDay (tech, dayStr) {
|
||||||
|
const dj = tech.queue.filter(j => getJobDate(j.id) === dayStr)
|
||||||
|
if (dj.length) { const last = dj[dj.length - 1]; if (last.coords && last.coords[0] !== 0) return last.coords }
|
||||||
|
return tech.coords
|
||||||
|
}
|
||||||
|
|
||||||
|
const criteria = dispatchCriteria.value.filter(c => c.enabled)
|
||||||
|
const sorted = [...unassigned].sort((a, b) => {
|
||||||
|
for (const c of criteria) {
|
||||||
|
if (c.id === 'urgency') {
|
||||||
|
const p = { high: 0, medium: 1, low: 2 }
|
||||||
|
const diff = (p[a.priority] ?? 2) - (p[b.priority] ?? 2)
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const useSkills = criteria.some(c => c.id === 'skills')
|
||||||
|
const weights = {}
|
||||||
|
criteria.forEach((c, i) => { weights[c.id] = criteria.length - i })
|
||||||
|
|
||||||
|
sorted.forEach(job => {
|
||||||
|
const assignDay = job.scheduledDate || today
|
||||||
|
let bestTech = null, bestScore = Infinity
|
||||||
|
techs.forEach(tech => {
|
||||||
|
let score = 0
|
||||||
|
if (weights.balance) score += techLoadForDay(tech, assignDay) * (weights.balance || 1)
|
||||||
|
if (weights.proximity && job.coords && (job.coords[0] !== 0 || job.coords[1] !== 0)) score += dist(techLastPosForDay(tech, assignDay), job.coords) / 60 * (weights.proximity || 1)
|
||||||
|
if (weights.skills && useSkills) {
|
||||||
|
const jt = job.tags || [], tt = tech.tags || []
|
||||||
|
score += (jt.length > 0 ? (jt.length - jt.filter(t => tt.includes(t)).length) * 2 : 0) * (weights.skills || 1)
|
||||||
|
}
|
||||||
|
if (score < bestScore) { bestScore = score; bestTech = tech }
|
||||||
|
})
|
||||||
|
if (bestTech) store.smartAssign(job.id, bestTech.id, assignDay)
|
||||||
|
})
|
||||||
|
|
||||||
|
pushUndo({ type: 'autoDistribute', assignments: prevAssignments, prevQueues })
|
||||||
|
bottomSelected.value = new Set()
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeRoute (tech) {
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr)
|
||||||
|
if (dayJobs.length < 2) return
|
||||||
|
const jobsWithCoords = dayJobs.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0))
|
||||||
|
if (jobsWithCoords.length < 2) return
|
||||||
|
|
||||||
|
const urgent = jobsWithCoords.filter(j => j.priority === 'high')
|
||||||
|
const normal = jobsWithCoords.filter(j => j.priority !== 'high')
|
||||||
|
|
||||||
|
function nearestNeighbor (start, jobs) {
|
||||||
|
const result = [], remaining = [...jobs]
|
||||||
|
let cur = start
|
||||||
|
while (remaining.length) {
|
||||||
|
let bi = 0, bd = Infinity
|
||||||
|
remaining.forEach((j, i) => {
|
||||||
|
const dx = j.coords[0] - cur[0], dy = j.coords[1] - cur[1], d = dx * dx + dy * dy
|
||||||
|
if (d < bd) { bd = d; bi = i }
|
||||||
|
})
|
||||||
|
result.push(remaining.splice(bi, 1)[0])
|
||||||
|
cur = result.at(-1).coords
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = (tech.coords?.[0] && tech.coords?.[1]) ? tech.coords : jobsWithCoords[0].coords
|
||||||
|
const orderedUrgent = nearestNeighbor(home, urgent)
|
||||||
|
const orderedNormal = nearestNeighbor(orderedUrgent.length ? orderedUrgent.at(-1).coords : home, normal)
|
||||||
|
const reordered = [...orderedUrgent, ...orderedNormal]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1])
|
||||||
|
const coords = []
|
||||||
|
if (hasHome) coords.push(`${tech.coords[0]},${tech.coords[1]}`)
|
||||||
|
reordered.forEach(j => coords.push(`${j.coords[0]},${j.coords[1]}`))
|
||||||
|
if (coords.length <= 12) {
|
||||||
|
const url = `https://api.mapbox.com/optimized-trips/v1/mapbox/driving/${coords.join(';')}?overview=false${hasHome ? '&source=first' : ''}&roundtrip=false&destination=any&access_token=${MAPBOX_TOKEN}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 'Ok' && data.waypoints) {
|
||||||
|
const off = hasHome ? 1 : 0, uc = orderedUrgent.length
|
||||||
|
const mu = reordered.slice(0, uc).map((j, i) => ({ job: j, o: data.waypoints[i + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job)
|
||||||
|
const mn = reordered.slice(uc).map((j, i) => ({ job: j, o: data.waypoints[i + uc + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job)
|
||||||
|
reordered.length = 0
|
||||||
|
reordered.push(...mu, ...mn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] })
|
||||||
|
const otherJobs = tech.queue.filter(j => getJobDate(j.id) !== dayStr)
|
||||||
|
tech.queue = [...reordered, ...otherJobs]
|
||||||
|
tech.queue.forEach((j, i) => {
|
||||||
|
j.routeOrder = i
|
||||||
|
updateJob(j.name || j.id, { route_order: i, start_time: '' }).catch(() => {})
|
||||||
|
})
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { autoDistribute, optimizeRoute }
|
||||||
|
}
|
||||||
120
apps/dispatch/src/composables/useBottomPanel.js
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
// ── Bottom panel composable: unassigned jobs table, multi-select, criteria ────
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { localDateStr } from './useHelpers'
|
||||||
|
|
||||||
|
export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart }) {
|
||||||
|
const bottomPanelOpen = ref(localStorage.getItem('sbv2-bottomPanel') !== 'false')
|
||||||
|
const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220)
|
||||||
|
watch(bottomPanelOpen, v => localStorage.setItem('sbv2-bottomPanel', v ? 'true' : 'false'))
|
||||||
|
|
||||||
|
// ── Grouped by date ──────────────────────────────────────────────────────────
|
||||||
|
const unassignedGrouped = computed(() => {
|
||||||
|
const today = todayStr
|
||||||
|
const jobs = unscheduledJobs.value.slice()
|
||||||
|
jobs.sort((a, b) => {
|
||||||
|
const da = a.scheduledDate || '9999-99-99'
|
||||||
|
const db = b.scheduledDate || '9999-99-99'
|
||||||
|
const aToday = da === today ? 0 : 1
|
||||||
|
const bToday = db === today ? 0 : 1
|
||||||
|
if (aToday !== bToday) return aToday - bToday
|
||||||
|
if (da !== db) return da.localeCompare(db)
|
||||||
|
const prio = { high: 0, medium: 1, low: 2 }
|
||||||
|
return (prio[a.priority] ?? 2) - (prio[b.priority] ?? 2)
|
||||||
|
})
|
||||||
|
const groups = []
|
||||||
|
let currentDate = null
|
||||||
|
jobs.forEach(job => {
|
||||||
|
const d = job.scheduledDate || null
|
||||||
|
if (d !== currentDate) {
|
||||||
|
currentDate = d
|
||||||
|
let label = d === today ? "Aujourd'hui" : d ? d : 'Sans date'
|
||||||
|
if (d && d !== today) {
|
||||||
|
const dt = new Date(d + 'T00:00:00')
|
||||||
|
label = dt.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
groups.push({ date: d, label, jobs: [] })
|
||||||
|
}
|
||||||
|
groups.at(-1).jobs.push(job)
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Resize ───────────────────────────────────────────────────────────────────
|
||||||
|
function startBottomResize (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const startY = e.clientY, startH = bottomPanelH.value
|
||||||
|
function onMove (ev) { bottomPanelH.value = Math.max(100, Math.min(window.innerHeight * 0.6, startH - (ev.clientY - startY))) }
|
||||||
|
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-bottomH', String(bottomPanelH.value)) }
|
||||||
|
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-select ─────────────────────────────────────────────────────────────
|
||||||
|
const bottomSelected = ref(new Set())
|
||||||
|
function toggleBottomSelect (jobId, event) {
|
||||||
|
const s = new Set(bottomSelected.value)
|
||||||
|
// Checkbox click: always toggle (no modifier needed)
|
||||||
|
// Shift+click: range select
|
||||||
|
if (event?.shiftKey && s.size) {
|
||||||
|
const flat = unassignedGrouped.value.flatMap(g => g.jobs)
|
||||||
|
const ids = flat.map(j => j.id)
|
||||||
|
const lastId = [...s].pop()
|
||||||
|
const fromIdx = ids.indexOf(lastId), toIdx = ids.indexOf(jobId)
|
||||||
|
if (fromIdx >= 0 && toIdx >= 0) {
|
||||||
|
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]
|
||||||
|
for (let i = lo; i <= hi; i++) s.add(ids[i])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple toggle (no Ctrl needed)
|
||||||
|
if (s.has(jobId)) s.delete(jobId); else s.add(jobId)
|
||||||
|
}
|
||||||
|
bottomSelected.value = s
|
||||||
|
}
|
||||||
|
function selectAllBottom () { const s = new Set(); unscheduledJobs.value.forEach(j => s.add(j.id)); bottomSelected.value = s }
|
||||||
|
function clearBottomSelect () { bottomSelected.value = new Set() }
|
||||||
|
function batchAssignBottom (techId) {
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
bottomSelected.value.forEach(jobId => {
|
||||||
|
const job = store.jobs.find(j => j.id === jobId)
|
||||||
|
if (job) {
|
||||||
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
||||||
|
smartAssign(job, techId, dayStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bottomSelected.value = new Set()
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispatch criteria ────────────────────────────────────────────────────────
|
||||||
|
const defaultCriteria = [
|
||||||
|
{ id: 'urgency', label: 'Urgence (priorité haute en premier)', enabled: true },
|
||||||
|
{ id: 'balance', label: 'Équilibrage de charge (tech le moins chargé)', enabled: true },
|
||||||
|
{ id: 'proximity', label: 'Proximité géographique', enabled: true },
|
||||||
|
{ id: 'skills', label: 'Correspondance des tags/skills', enabled: false },
|
||||||
|
]
|
||||||
|
const dispatchCriteria = ref(JSON.parse(localStorage.getItem('sbv2-dispatchCriteria') || 'null') || defaultCriteria.map(c => ({ ...c })))
|
||||||
|
const dispatchCriteriaModal = ref(false)
|
||||||
|
function saveDispatchCriteria () { localStorage.setItem('sbv2-dispatchCriteria', JSON.stringify(dispatchCriteria.value)); dispatchCriteriaModal.value = false }
|
||||||
|
function moveCriterion (idx, dir) {
|
||||||
|
const arr = dispatchCriteria.value, newIdx = idx + dir
|
||||||
|
if (newIdx < 0 || newIdx >= arr.length) return
|
||||||
|
const tmp = arr[idx]; arr[idx] = arr[newIdx]; arr[newIdx] = tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Column widths ────────────────────────────────────────────────────────────
|
||||||
|
const btColWidths = ref(JSON.parse(localStorage.getItem('sbv2-btColW') || '{}'))
|
||||||
|
function btColW (col, def) { return (btColWidths.value[col] || def) + 'px' }
|
||||||
|
function startColResize (e, col) {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
const startX = e.clientX, startW = btColWidths.value[col] || parseInt(getComputedStyle(e.target.parentElement).width)
|
||||||
|
function onMove (ev) { btColWidths.value = { ...btColWidths.value, [col]: Math.max(40, startW + (ev.clientX - startX)) } }
|
||||||
|
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-btColW', JSON.stringify(btColWidths.value)) }
|
||||||
|
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
|
||||||
|
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
|
||||||
|
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
|
||||||
|
btColWidths, btColW, startColResize,
|
||||||
|
}
|
||||||
|
}
|
||||||
242
apps/dispatch/src/composables/useDragDrop.js
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
// ── Drag & Drop composable: job drag, tech drag, block move, block resize, batch drag ──
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { snapH, hToTime, fmtDur, localDateStr, SNAP, serializeAssistants } from './useHelpers'
|
||||||
|
import { updateJob } from 'src/api/dispatch'
|
||||||
|
|
||||||
|
export function useDragDrop (deps) {
|
||||||
|
const {
|
||||||
|
store, pxPerHr, dayW, periodStart, periodDays, H_START,
|
||||||
|
getJobDate, bottomSelected, multiSelect,
|
||||||
|
pushUndo, smartAssign, invalidateRoutes,
|
||||||
|
} = deps
|
||||||
|
|
||||||
|
const dragJob = ref(null)
|
||||||
|
const dragSrc = ref(null)
|
||||||
|
const dragIsAssist = ref(false)
|
||||||
|
const dropGhost = ref(null)
|
||||||
|
const dragTech = ref(null)
|
||||||
|
const dragBatchIds = ref(null)
|
||||||
|
|
||||||
|
function cleanupDropIndicators () {
|
||||||
|
document.querySelectorAll('.sb-block-drop-hover').forEach(el => el.classList.remove('sb-block-drop-hover'))
|
||||||
|
dropGhost.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJobDragStart (e, job, srcTechId, isAssist = false) {
|
||||||
|
dragJob.value = job; dragSrc.value = srcTechId || null; dragIsAssist.value = isAssist
|
||||||
|
if (!srcTechId && bottomSelected.value.size > 1 && bottomSelected.value.has(job.id)) {
|
||||||
|
dragBatchIds.value = new Set(bottomSelected.value)
|
||||||
|
e.dataTransfer.setData('text/plain', `batch:${dragBatchIds.value.size}`)
|
||||||
|
} else {
|
||||||
|
dragBatchIds.value = null
|
||||||
|
}
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.target.addEventListener('dragend', () => { cleanupDropIndicators(); dragIsAssist.value = false; dragBatchIds.value = null }, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTimelineDragOver (e, tech) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!dragJob.value && !dragTech.value) return
|
||||||
|
const x = e.clientX - e.currentTarget.getBoundingClientRect().left
|
||||||
|
dropGhost.value = { techId: tech.id, x, dateStr: xToDateStr(x) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTimelineDragLeave (e) {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget)) dropGhost.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTechDragStart (e, tech) {
|
||||||
|
dragTech.value = tech
|
||||||
|
e.dataTransfer.effectAllowed = 'copyMove'
|
||||||
|
e.dataTransfer.setData('text/plain', tech.id)
|
||||||
|
e.target.addEventListener('dragend', () => { dragTech.value = null; cleanupDropIndicators() }, { once: true })
|
||||||
|
return tech
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlockDrop (e, job) {
|
||||||
|
if (dragTech.value) {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
cleanupDropIndicators()
|
||||||
|
pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' })
|
||||||
|
store.addAssistant(job.id, dragTech.value.id)
|
||||||
|
dragTech.value = null
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignDroppedJob (tech, dateStr) {
|
||||||
|
if (!dragJob.value) return
|
||||||
|
if (dragIsAssist.value) {
|
||||||
|
dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return
|
||||||
|
}
|
||||||
|
if (dragBatchIds.value && dragBatchIds.value.size > 1) {
|
||||||
|
const prevStates = []
|
||||||
|
dragBatchIds.value.forEach(jobId => {
|
||||||
|
const j = store.jobs.find(x => x.id === jobId)
|
||||||
|
if (j && !j.assignedTech) {
|
||||||
|
prevStates.push({ jobId: j.id, techId: j.assignedTech, routeOrder: j.routeOrder, scheduledDate: j.scheduledDate, assistants: [...(j.assistants || [])] })
|
||||||
|
smartAssign(j, tech.id, dateStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, targetTechId: tech.id })
|
||||||
|
bottomSelected.value = new Set()
|
||||||
|
dragBatchIds.value = null
|
||||||
|
} else if (multiSelect && multiSelect.value?.length > 1 && multiSelect.value.some(s => s.job.id === dragJob.value.id)) {
|
||||||
|
// Dragging a multi-selected block from timeline — move all selected
|
||||||
|
const prevStates = []
|
||||||
|
const prevQueues = {}
|
||||||
|
store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] })
|
||||||
|
multiSelect.value.filter(s => !s.isAssist).forEach(s => {
|
||||||
|
prevStates.push({ jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder, scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])] })
|
||||||
|
smartAssign(s.job, tech.id, dateStr)
|
||||||
|
})
|
||||||
|
if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, prevQueues })
|
||||||
|
multiSelect.value = []
|
||||||
|
} else {
|
||||||
|
const job = dragJob.value
|
||||||
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
||||||
|
smartAssign(job, tech.id, dateStr)
|
||||||
|
}
|
||||||
|
dropGhost.value = null; dragJob.value = null; dragSrc.value = null
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTimelineDrop (e, tech) {
|
||||||
|
e.preventDefault()
|
||||||
|
cleanupDropIndicators()
|
||||||
|
|
||||||
|
if (dragTech.value) {
|
||||||
|
const els = document.elementsFromPoint(e.clientX, e.clientY)
|
||||||
|
const blockEl = els.find(el => el.dataset?.jobId)
|
||||||
|
if (blockEl) {
|
||||||
|
const job = store.jobs.find(j => j.id === blockEl.dataset.jobId)
|
||||||
|
if (job) {
|
||||||
|
pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' })
|
||||||
|
store.addAssistant(job.id, dragTech.value.id)
|
||||||
|
dragTech.value = null; invalidateRoutes(); return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragTech.value = null; return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dragJob.value) return
|
||||||
|
|
||||||
|
if (dragJob.value.assignedTech === tech.id) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const x = (e.clientX || e.pageX) - rect.left
|
||||||
|
const dropH = H_START + x / pxPerHr.value
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] })
|
||||||
|
const draggedJob = dragJob.value
|
||||||
|
tech.queue = tech.queue.filter(j => j.id !== draggedJob.id)
|
||||||
|
const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr)
|
||||||
|
const queueDayStart = tech.queue.findIndex(j => getJobDate(j.id) === dayStr)
|
||||||
|
let slot = dayJobs.length, cursor = 8
|
||||||
|
for (let i = 0; i < dayJobs.length; i++) {
|
||||||
|
const dur = parseFloat(dayJobs[i].duration) || 1
|
||||||
|
if (dropH < cursor + dur / 2) { slot = i; break }
|
||||||
|
cursor += dur + 0.5
|
||||||
|
}
|
||||||
|
const insertAt = queueDayStart >= 0 ? queueDayStart + slot : tech.queue.length
|
||||||
|
tech.queue.splice(insertAt, 0, draggedJob)
|
||||||
|
tech.queue.forEach((q, i) => { q.routeOrder = i; updateJob(q.name || q.id, { route_order: i }).catch(() => {}) })
|
||||||
|
dragJob.value = null; dragSrc.value = null; invalidateRoutes(); return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragIsAssist.value) {
|
||||||
|
dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignDroppedJob(tech, xToDateStr(e.clientX - e.currentTarget.getBoundingClientRect().left))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCalDrop (e, tech, dateStr) { assignDroppedJob(tech, dateStr) }
|
||||||
|
|
||||||
|
function xToDateStr (x) {
|
||||||
|
const di = Math.max(0, Math.min(periodDays.value - 1, Math.floor(x / dayW.value)))
|
||||||
|
const d = new Date(periodStart.value); d.setDate(d.getDate() + di)
|
||||||
|
return localDateStr(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBlockMove (e, job, block) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
const startX = e.clientX, startY = e.clientY
|
||||||
|
const startLeft = parseFloat(block.style.left) || 0
|
||||||
|
let moving = false
|
||||||
|
function onMove (ev) {
|
||||||
|
const dx = ev.clientX - startX, dy = ev.clientY - startY
|
||||||
|
if (!moving && Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 5) { cleanup(); return }
|
||||||
|
if (!moving && Math.abs(dx) > 5) { moving = true; block.style.zIndex = '10' }
|
||||||
|
if (!moving) return
|
||||||
|
ev.preventDefault()
|
||||||
|
const newLeft = Math.max(0, startLeft + dx)
|
||||||
|
const newH = snapH(H_START + newLeft / pxPerHr.value)
|
||||||
|
block.style.left = ((newH - H_START) * pxPerHr.value) + 'px'
|
||||||
|
const meta = block.querySelector('.sb-block-meta')
|
||||||
|
if (meta) meta.textContent = `${hToTime(newH)} · ${fmtDur(job.duration)}`
|
||||||
|
}
|
||||||
|
function cleanup () {
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
function onUp (ev) {
|
||||||
|
cleanup()
|
||||||
|
if (!moving) return
|
||||||
|
block.style.zIndex = ''
|
||||||
|
const dx = ev.clientX - startX
|
||||||
|
const newH = snapH(H_START + Math.max(0, startLeft + dx) / pxPerHr.value)
|
||||||
|
job.startHour = newH; job.startTime = hToTime(newH)
|
||||||
|
store.setJobSchedule(job.id, job.scheduledDate, hToTime(newH))
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize (e, job, mode, assistTechId) {
|
||||||
|
e.preventDefault()
|
||||||
|
const startX = e.clientX
|
||||||
|
const startDur = mode === 'assist'
|
||||||
|
? (job.assistants.find(a => a.techId === assistTechId)?.duration || job.duration)
|
||||||
|
: job.duration
|
||||||
|
const block = e.target.parentElement
|
||||||
|
const startW = block.offsetWidth
|
||||||
|
function onMove (ev) {
|
||||||
|
const dx = ev.clientX - startX
|
||||||
|
const newDur = Math.max(SNAP, snapH(Math.max(18, startW + dx) / pxPerHr.value))
|
||||||
|
block.style.width = (newDur * pxPerHr.value) + 'px'
|
||||||
|
const meta = block.querySelector('.sb-block-meta')
|
||||||
|
if (meta) meta.textContent = mode === 'assist' ? `assistant · ${fmtDur(newDur)}` : fmtDur(newDur)
|
||||||
|
}
|
||||||
|
function onUp (ev) {
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onUp)
|
||||||
|
const dx = ev.clientX - startX
|
||||||
|
const newDur = Math.max(SNAP, snapH(Math.max(18, startW + dx) / pxPerHr.value))
|
||||||
|
if (mode === 'assist' && assistTechId) {
|
||||||
|
const assist = job.assistants.find(a => a.techId === assistTechId)
|
||||||
|
if (assist) {
|
||||||
|
assist.duration = newDur
|
||||||
|
updateJob(job.name || job.id, {
|
||||||
|
assistants: serializeAssistants(job.assistants),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
job.duration = newDur
|
||||||
|
updateJob(job.name || job.id, { duration_h: newDur }).catch(() => {})
|
||||||
|
}
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragJob, dragSrc, dragIsAssist, dropGhost, dragTech, dragBatchIds,
|
||||||
|
cleanupDropIndicators,
|
||||||
|
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
|
||||||
|
onTechDragStart, onBlockDrop,
|
||||||
|
assignDroppedJob, onTimelineDrop, onCalDrop, xToDateStr,
|
||||||
|
startBlockMove, startResize,
|
||||||
|
}
|
||||||
|
}
|
||||||
162
apps/dispatch/src/composables/useHelpers.js
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// ── Pure utility functions (no Vue dependencies) ─────────────────────────────
|
||||||
|
export function localDateStr (d) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfWeek (d) {
|
||||||
|
const r = new Date(d); r.setHours(0,0,0,0)
|
||||||
|
const diff = r.getDay() === 0 ? -6 : 1 - r.getDay()
|
||||||
|
r.setDate(r.getDate() + diff); return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfMonth (d) { return new Date(d.getFullYear(), d.getMonth(), 1) }
|
||||||
|
|
||||||
|
export function timeToH (t) {
|
||||||
|
const [h, m] = t.split(':').map(Number)
|
||||||
|
return h + m / 60
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hToTime (h) {
|
||||||
|
const totalMin = Math.round(h * 60)
|
||||||
|
const hh = Math.floor(totalMin / 60)
|
||||||
|
const mm = totalMin % 60
|
||||||
|
return `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDur (h) {
|
||||||
|
const totalMin = Math.round((parseFloat(h) || 0) * 60)
|
||||||
|
const hh = Math.floor(totalMin / 60)
|
||||||
|
const mm = totalMin % 60
|
||||||
|
if (hh === 0) return `${mm}m`
|
||||||
|
if (mm === 0) return `${hh}h`
|
||||||
|
return `${hh}h${String(mm).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SNAP_MIN = 5
|
||||||
|
export const SNAP = SNAP_MIN / 60
|
||||||
|
export function snapH (h) { return Math.round(h * 60 / SNAP_MIN) * SNAP_MIN / 60 }
|
||||||
|
|
||||||
|
export function dayLoadColor (ratio) {
|
||||||
|
const r = Math.min(ratio, 1.2)
|
||||||
|
if (r <= 0.5) return '#10b981'
|
||||||
|
if (r <= 0.75) return '#f59e0b'
|
||||||
|
if (r <= 1) return '#f97316'
|
||||||
|
return '#ef4444'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortAddr (addr) {
|
||||||
|
if (!addr) return ''
|
||||||
|
const parts = addr.replace(/[A-Z]\d[A-Z]\s?\d[A-Z]\d/g, '').trim().split(/[\s,]+/)
|
||||||
|
for (let i = parts.length - 1; i >= 0; i--) {
|
||||||
|
if (parts[i].length > 2 && /^[A-ZÀ-Ú]/.test(parts[i])) return parts[i]
|
||||||
|
}
|
||||||
|
return parts.slice(-2).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service colors & labels
|
||||||
|
export const SVC_COLORS = { 'Internet':'#3b82f6','Télévisión':'#a855f7','Téléphonie':'#10b981','Multi-service':'#f59e0b' }
|
||||||
|
export const SVC_ICONS = { 'Internet':'🌐','Télévisión':'📺','Téléphonie':'📞','Multi-service':'🔧' }
|
||||||
|
const SVC_CODES = { 'Internet':'WEB','Télévisión':'TV','Téléphonie':'TEL','Multi-service':'MX' }
|
||||||
|
|
||||||
|
export function jobSvcCode (job) {
|
||||||
|
if (SVC_CODES[job.service_type]) return SVC_CODES[job.service_type]
|
||||||
|
const s = (job.subject || '').toLowerCase()
|
||||||
|
if (s.includes('internet')) return 'WEB'
|
||||||
|
if (s.includes('tv') || s.includes('télév')) return 'TV'
|
||||||
|
if (s.includes('téléph')) return 'TEL'
|
||||||
|
if (s.includes('multi')) return 'MX'
|
||||||
|
return 'WO'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobColor (job, techColors, store) {
|
||||||
|
if (SVC_COLORS[job.service_type]) return SVC_COLORS[job.service_type]
|
||||||
|
const s = (job.subject||'').toLowerCase()
|
||||||
|
if (s.includes('internet')) return '#3b82f6'
|
||||||
|
if (s.includes('tv')||s.includes('télév')) return '#a855f7'
|
||||||
|
if (s.includes('téléph')) return '#10b981'
|
||||||
|
if (s.includes('multi')) return '#f59e0b'
|
||||||
|
if (job.assignedTech && store) {
|
||||||
|
const t = store.technicians.find(x=>x.id===job.assignedTech)
|
||||||
|
if (t) return techColors[t.colorIdx]
|
||||||
|
}
|
||||||
|
return '#6b7280'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobSpansDate (job, ds) {
|
||||||
|
const start = job.scheduledDate
|
||||||
|
const end = job.endDate
|
||||||
|
if (!start) return false
|
||||||
|
if (!end) return start === ds
|
||||||
|
return ds >= start && ds <= end
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortJobsByTime (jobs) {
|
||||||
|
return jobs.slice().sort((a, b) => {
|
||||||
|
const aH = a.startTime ? timeToH(a.startTime) : (a.startHour ?? 8)
|
||||||
|
const bH = b.startTime ? timeToH(b.startTime) : (b.startHour ?? 8)
|
||||||
|
return aH - bH
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status helpers
|
||||||
|
export const STATUS_MAP = {
|
||||||
|
'available': { cls:'st-available', label:'Disponible' },
|
||||||
|
'en-route': { cls:'st-enroute', label:'En route' },
|
||||||
|
'busy': { cls:'st-busy', label:'En cours' },
|
||||||
|
'in progress': { cls:'st-busy', label:'En cours' },
|
||||||
|
'off': { cls:'st-off', label:'Hors shift' },
|
||||||
|
}
|
||||||
|
export function stOf (t) { return STATUS_MAP[(t.status||'').toLowerCase()] || STATUS_MAP['available'] }
|
||||||
|
|
||||||
|
export function prioLabel (p) { return { high:'Haute', medium:'Moyenne', low:'Basse' }[p] || p || '—' }
|
||||||
|
export function prioClass (p) { return { high:'prio-high', medium:'prio-med', low:'prio-low' }[p] || '' }
|
||||||
|
|
||||||
|
// Lucide-style inline SVG icons (stroke-based)
|
||||||
|
const _s = (d, w=10) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`
|
||||||
|
|
||||||
|
export const ICON = {
|
||||||
|
pin: _s('<path d="M12 17v5"/><path d="M9 11V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v7"/><path d="M4 15h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"/>'),
|
||||||
|
mapPin: _s('<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>'),
|
||||||
|
wifi: _s('<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/>'),
|
||||||
|
tv: _s('<rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><path d="m17 2-5 5-5-5"/>'),
|
||||||
|
phone: _s('<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>'),
|
||||||
|
wrench: _s('<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>'),
|
||||||
|
cable: _s('<path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><path d="M8 7V4"/><path d="M16 7V4"/><path d="M12 16v4"/>'),
|
||||||
|
check: _s('<path d="M20 6L9 17l-5-5"/>'),
|
||||||
|
x: _s('<path d="M18 6L6 18M6 6l12 12"/>'),
|
||||||
|
clock: _s('<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>'),
|
||||||
|
loader: _s('<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>'),
|
||||||
|
truck: _s('<path d="M5 17h2l3-6h4l3 6h2M7 17a2 2 0 1 1-4 0M21 17a2 2 0 1 1-4 0"/>'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job type icon based on service/subject
|
||||||
|
export function jobTypeIcon (job) {
|
||||||
|
const s = (job.subject || '').toLowerCase()
|
||||||
|
const svc = job.service_type || ''
|
||||||
|
if (svc === 'Internet' || s.includes('internet') || s.includes('fibre') || s.includes('routeur') || s.includes('wifi')) return ICON.wifi
|
||||||
|
if (svc === 'Télévisión' || s.includes('tv') || s.includes('télév')) return ICON.tv
|
||||||
|
if (svc === 'Téléphonie' || s.includes('téléph') || s.includes('phone')) return ICON.phone
|
||||||
|
if (s.includes('cable') || s.includes('câble') || s.includes('cablage')) return ICON.cable
|
||||||
|
if (s.includes('camera') || s.includes('install')) return ICON.wrench
|
||||||
|
return ICON.wrench
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority color
|
||||||
|
export function prioColor (p) {
|
||||||
|
return { high: '#ef4444', medium: '#f59e0b', low: '#7b80a0' }[p] || '#7b80a0'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status icon (minimal, for timeline blocks)
|
||||||
|
// Serialize assistants array for ERPNext API calls (used in store + page)
|
||||||
|
export function serializeAssistants (assistants) {
|
||||||
|
return (assistants || []).map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobStatusIcon (job) {
|
||||||
|
const st = (job.status || '').toLowerCase()
|
||||||
|
if (st === 'completed') return { svg: ICON.check, cls: 'si-done' }
|
||||||
|
if (st === 'cancelled') return { svg: ICON.x, cls: 'si-cancelled' }
|
||||||
|
if (st === 'en-route') return { svg: ICON.truck, cls: 'si-enroute' }
|
||||||
|
if (st === 'in progress') return { svg: ICON.loader, cls: 'si-progress' }
|
||||||
|
return { svg: '', cls: '' } // no icon for open/assigned — the type icon is enough
|
||||||
|
}
|
||||||
413
apps/dispatch/src/composables/useMap.js
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
// ── Map composable: Mapbox GL map, markers, routes, geo-fix, map-drag ────────
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import { localDateStr, jobSpansDate, jobSvcCode, SVC_COLORS } from './useHelpers'
|
||||||
|
|
||||||
|
export function useMap (deps) {
|
||||||
|
const {
|
||||||
|
store, MAPBOX_TOKEN, TECH_COLORS,
|
||||||
|
currentView, periodStart, filteredResources, mapVisible,
|
||||||
|
routeLegs, routeGeometry,
|
||||||
|
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
|
||||||
|
dragJob, dragIsAssist, rightPanel, openCtxMenu,
|
||||||
|
} = deps
|
||||||
|
|
||||||
|
let map = null
|
||||||
|
let mapResizeObs = null
|
||||||
|
const mapContainer = ref(null)
|
||||||
|
const selectedTechId = ref(null)
|
||||||
|
const mapMarkers = ref([])
|
||||||
|
const mapPanelW = ref(parseInt(localStorage.getItem('sbv2-mapW')) || 340)
|
||||||
|
const geoFixJob = ref(null)
|
||||||
|
const mapDragJob = ref(null)
|
||||||
|
let _mapGhost = null
|
||||||
|
|
||||||
|
// ── Geo-fix ──────────────────────────────────────────────────────────────────
|
||||||
|
function startGeoFix (job) {
|
||||||
|
geoFixJob.value = job
|
||||||
|
if (!mapVisible.value) mapVisible.value = true
|
||||||
|
if (map) map.getCanvas().style.cursor = 'crosshair'
|
||||||
|
}
|
||||||
|
function cancelGeoFix () {
|
||||||
|
geoFixJob.value = null
|
||||||
|
if (map) map.getCanvas().style.cursor = ''
|
||||||
|
}
|
||||||
|
watch(geoFixJob, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' })
|
||||||
|
|
||||||
|
// ── Panel resize ─────────────────────────────────────────────────────────────
|
||||||
|
function startMapResize (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const startX = e.clientX, startW = mapPanelW.value
|
||||||
|
function onMove (ev) {
|
||||||
|
mapPanelW.value = Math.max(220, Math.min(window.innerWidth * 0.65, startW - (ev.clientX - startX)))
|
||||||
|
}
|
||||||
|
function onUp () {
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onUp)
|
||||||
|
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
|
||||||
|
if (map) map.resize()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
async function initMap () {
|
||||||
|
if (!mapContainer.value || map) return
|
||||||
|
if (!window.mapboxgl) {
|
||||||
|
if (!document.getElementById('mapbox-js')) {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
const s = document.createElement('script'); s.id = 'mapbox-js'
|
||||||
|
s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'
|
||||||
|
s.onload = resolve; document.head.appendChild(s)
|
||||||
|
})
|
||||||
|
} else { await new Promise(r => setTimeout(r, 200)) }
|
||||||
|
}
|
||||||
|
const mapboxgl = window.mapboxgl
|
||||||
|
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||||
|
map = new mapboxgl.Map({
|
||||||
|
container: mapContainer.value,
|
||||||
|
style: 'mapbox://styles/mapbox/dark-v11',
|
||||||
|
center: [-73.567, 45.502], zoom: 10,
|
||||||
|
})
|
||||||
|
if (mapResizeObs) mapResizeObs.disconnect()
|
||||||
|
mapResizeObs = new ResizeObserver(() => { if (map) map.resize() })
|
||||||
|
mapResizeObs.observe(mapContainer.value)
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
map.resize()
|
||||||
|
// Route layers
|
||||||
|
map.addSource('sb-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
map.addLayer({ id: 'sb-route-halo', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 12, 'line-opacity': 0.18 } })
|
||||||
|
map.addLayer({ id: 'sb-route-line', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 3.5, 'line-opacity': 0.85 } })
|
||||||
|
// Job layers
|
||||||
|
map.addSource('sb-jobs', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
map.addLayer({ id: 'sb-jobs-halo', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 22, 'circle-color': ['get', 'color'], 'circle-opacity': ['*', ['get', 'opacity'], 0.18], 'circle-blur': 0.7 } })
|
||||||
|
map.addLayer({ id: 'sb-jobs-circle', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 15, 'circle-color': ['get', 'color'], 'circle-opacity': ['get', 'opacity'], 'circle-stroke-width': 2, 'circle-stroke-color': ['case', ['get', 'unassigned'], 'rgba(255,255,255,0.4)', 'rgba(255,255,255,0.85)'], 'circle-stroke-opacity': ['get', 'opacity'] } })
|
||||||
|
map.addLayer({ id: 'sb-jobs-label', type: 'symbol', source: 'sb-jobs', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 9, 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': '#ffffff', 'text-opacity': ['get', 'opacity'] } })
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
map.on('mouseenter', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = 'grab' })
|
||||||
|
map.on('mouseleave', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = '' })
|
||||||
|
map.on('mousedown', 'sb-jobs-circle', e => {
|
||||||
|
if (geoFixJob.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
|
||||||
|
if (job) startMapDrag(e.originalEvent, job)
|
||||||
|
})
|
||||||
|
map.on('click', 'sb-jobs-circle', e => {
|
||||||
|
if (geoFixJob.value) return
|
||||||
|
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
|
||||||
|
if (job) {
|
||||||
|
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
|
||||||
|
rightPanel.value = { mode: 'details', data: { job, tech } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
map.on('contextmenu', 'sb-jobs-circle', e => {
|
||||||
|
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
|
||||||
|
if (job) {
|
||||||
|
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
|
||||||
|
openCtxMenu(e.originalEvent, job, tech?.id || null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
map.on('mouseenter', 'sb-route-line', () => { if (mapDragJob.value) map.getCanvas().style.cursor = 'copy' })
|
||||||
|
map.on('mouseleave', 'sb-route-line', () => { if (!mapDragJob.value) map.getCanvas().style.cursor = '' })
|
||||||
|
|
||||||
|
// Geo-fix click
|
||||||
|
map.on('click', e => {
|
||||||
|
if (!geoFixJob.value) return
|
||||||
|
const job = geoFixJob.value
|
||||||
|
const saved = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
|
||||||
|
saved[job.id] = [e.lngLat.lng, e.lngLat.lat]
|
||||||
|
localStorage.setItem('dispatch-job-coords', JSON.stringify(saved))
|
||||||
|
store.updateJobCoords(job.id, e.lngLat.lng, e.lngLat.lat)
|
||||||
|
routeLegs.value = {}; routeGeometry.value = {}
|
||||||
|
geoFixJob.value = null
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
nextTick(() => {
|
||||||
|
drawMapMarkers()
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
filteredResources.value.forEach(tech => computeDayRoute(tech, dayStr))
|
||||||
|
drawSelectedRoute()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
drawMapMarkers()
|
||||||
|
drawSelectedRoute()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw markers ─────────────────────────────────────────────────────────────
|
||||||
|
function drawMapMarkers () {
|
||||||
|
if (!map || !window.mapboxgl) return
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
const mbgl = window.mapboxgl
|
||||||
|
|
||||||
|
const jobFeatures = store.jobs
|
||||||
|
.filter(j => j.coords && !(j.coords[0] === 0 && j.coords[1] === 0))
|
||||||
|
.filter(j => {
|
||||||
|
if (!j.assignedTech) return (j.scheduledDate || null) === dayStr
|
||||||
|
return jobSpansDate(j, dayStr)
|
||||||
|
})
|
||||||
|
.map(job => {
|
||||||
|
const isUnassigned = !job.assignedTech
|
||||||
|
const isCompleted = (job.status || '').toLowerCase() === 'completed'
|
||||||
|
const isSelected = selectedTechId.value && job.assignedTech === selectedTechId.value
|
||||||
|
const opacity = isCompleted ? 0.4 : (isSelected || isUnassigned || !selectedTechId.value ? 0.92 : 0.4)
|
||||||
|
let label = jobSvcCode(job)
|
||||||
|
if (!isUnassigned) {
|
||||||
|
const tech = store.technicians.find(t => t.id === job.assignedTech)
|
||||||
|
if (tech) { const idx = tech.queue.filter(j2 => getJobDate(j2.id) === dayStr).indexOf(job); if (idx >= 0) label = String(idx + 1) }
|
||||||
|
}
|
||||||
|
return { type: 'Feature', geometry: { type: 'Point', coordinates: job.coords }, properties: { id: job.id, color: jobColor(job), label, title: job.subject, opacity, unassigned: isUnassigned, completed: isCompleted } }
|
||||||
|
})
|
||||||
|
if (map.getSource('sb-jobs')) map.getSource('sb-jobs').setData({ type: 'FeatureCollection', features: jobFeatures })
|
||||||
|
|
||||||
|
// Tech avatar markers
|
||||||
|
mapMarkers.value.forEach(m => m.remove())
|
||||||
|
mapMarkers.value = []
|
||||||
|
|
||||||
|
// Pre-compute: which techs are assistants on which lead tech's jobs today
|
||||||
|
const groupCounts = {} // leadTechId → total crew size (1 + assistants)
|
||||||
|
store.technicians.forEach(tech => {
|
||||||
|
const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr))
|
||||||
|
const assistIds = new Set()
|
||||||
|
todayJobs.forEach(j => (j.assistants || []).forEach(a => assistIds.add(a.techId)))
|
||||||
|
if (assistIds.size > 0) groupCounts[tech.id] = 1 + assistIds.size
|
||||||
|
})
|
||||||
|
|
||||||
|
filteredResources.value.forEach(tech => {
|
||||||
|
const pos = tech.gpsCoords || tech.coords
|
||||||
|
if (!pos || (pos[0] === 0 && pos[1] === 0)) return
|
||||||
|
const initials = tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||||
|
const color = TECH_COLORS[tech.colorIdx]
|
||||||
|
|
||||||
|
// Calculate daily workload + completion
|
||||||
|
const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr))
|
||||||
|
const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr))
|
||||||
|
const allToday = [...todayJobs, ...todayAssist]
|
||||||
|
const totalHours = allToday.reduce((s, j) => s + (j.duration || 1), 0)
|
||||||
|
const doneHours = allToday.filter(j => (j.status || '').toLowerCase() === 'completed')
|
||||||
|
.reduce((s, j) => s + (j.duration || 1), 0)
|
||||||
|
const loadPct = Math.min(totalHours / 8, 1)
|
||||||
|
const donePct = totalHours > 0 ? Math.min(doneHours / 8, 1) : 0
|
||||||
|
const loadColor = loadPct < 0.5 ? '#10b981' : loadPct < 0.75 ? '#f59e0b' : loadPct < 0.9 ? '#f97316' : '#ef4444'
|
||||||
|
|
||||||
|
// Ring + avatar in a fixed-size container so Mapbox anchor stays consistent
|
||||||
|
const PIN = 36, STROKE = 3.5, SIZE = PIN + STROKE * 2 + 2 // ~45px
|
||||||
|
const R = (SIZE - STROKE) / 2, CIRC = 2 * Math.PI * R
|
||||||
|
const completedJobs = allToday.filter(j => (j.status || '').toLowerCase() === 'completed').length
|
||||||
|
const totalJobs = allToday.length
|
||||||
|
const completionPct = totalJobs > 0 ? completedJobs / totalJobs : 0
|
||||||
|
|
||||||
|
// Fixed-size outer wrapper — Mapbox anchors to this
|
||||||
|
const outer = document.createElement('div')
|
||||||
|
outer.style.cssText = `cursor:pointer;width:${SIZE}px;height:${SIZE}px;position:relative;`
|
||||||
|
outer.dataset.techId = tech.id
|
||||||
|
|
||||||
|
// SVG ring (load arc + completion arc) — fills entire container
|
||||||
|
if (totalHours > 0) {
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
svg.setAttribute('width', SIZE); svg.setAttribute('height', SIZE)
|
||||||
|
svg.style.cssText = 'position:absolute;top:0;left:0;transform:rotate(-90deg);pointer-events:none;'
|
||||||
|
const loadArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
||||||
|
loadArc.setAttribute('cx', SIZE/2); loadArc.setAttribute('cy', SIZE/2); loadArc.setAttribute('r', R)
|
||||||
|
loadArc.setAttribute('fill', 'none'); loadArc.setAttribute('stroke', loadColor)
|
||||||
|
loadArc.setAttribute('stroke-width', STROKE); loadArc.setAttribute('opacity', '0.3')
|
||||||
|
loadArc.setAttribute('stroke-dasharray', `${CIRC * loadPct} ${CIRC}`)
|
||||||
|
loadArc.setAttribute('stroke-linecap', 'round')
|
||||||
|
svg.appendChild(loadArc)
|
||||||
|
if (completionPct > 0) {
|
||||||
|
const doneArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
||||||
|
doneArc.setAttribute('cx', SIZE/2); doneArc.setAttribute('cy', SIZE/2); doneArc.setAttribute('r', R)
|
||||||
|
doneArc.setAttribute('fill', 'none'); doneArc.setAttribute('stroke', '#10b981')
|
||||||
|
doneArc.setAttribute('stroke-width', STROKE); doneArc.setAttribute('opacity', '1')
|
||||||
|
doneArc.setAttribute('stroke-dasharray', `${CIRC * completionPct * loadPct} ${CIRC}`)
|
||||||
|
doneArc.setAttribute('stroke-linecap', 'round')
|
||||||
|
svg.appendChild(doneArc)
|
||||||
|
}
|
||||||
|
outer.appendChild(svg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatar circle — absolutely centered in container
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'sb-map-tech-pin'
|
||||||
|
const offset = (SIZE - PIN) / 2
|
||||||
|
el.style.cssText = `background:${color};border-color:${color};position:absolute;top:${offset}px;left:${offset}px;width:${PIN}px;height:${PIN}px;`
|
||||||
|
el.textContent = initials
|
||||||
|
el.title = `${tech.fullName} — ${completedJobs}/${totalJobs} jobs (${doneHours.toFixed(1)}h / ${totalHours.toFixed(1)}h)`
|
||||||
|
outer.appendChild(el)
|
||||||
|
|
||||||
|
// Group badge (crew size)
|
||||||
|
const crew = groupCounts[tech.id]
|
||||||
|
if (crew && crew > 1) {
|
||||||
|
const badge = document.createElement('div')
|
||||||
|
badge.className = 'sb-map-crew-badge'
|
||||||
|
badge.textContent = String(crew)
|
||||||
|
badge.title = `Équipe de ${crew}`
|
||||||
|
el.appendChild(badge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag & drop handlers
|
||||||
|
outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' })
|
||||||
|
outer.addEventListener('dragleave', () => { el.style.transform = '' })
|
||||||
|
outer.addEventListener('drop', e => {
|
||||||
|
e.preventDefault(); el.style.transform = ''
|
||||||
|
const job = dragJob.value
|
||||||
|
if (job) {
|
||||||
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
||||||
|
smartAssign(job, tech.id, dayStr)
|
||||||
|
dragJob.value = null
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' })
|
||||||
|
outer.addEventListener('mouseleave', () => { el.style.transform = '' })
|
||||||
|
|
||||||
|
if (tech.gpsCoords) {
|
||||||
|
el.classList.add('sb-map-gps-active')
|
||||||
|
el.title += ' (GPS)'
|
||||||
|
}
|
||||||
|
const m = new mbgl.Marker({ element: outer, anchor: 'center' }).setLngLat(pos).addTo(map)
|
||||||
|
mapMarkers.value.push(m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map drag (job pin → tech) ────────────────────────────────────────────────
|
||||||
|
function startMapDrag (e, job) {
|
||||||
|
e.preventDefault()
|
||||||
|
mapDragJob.value = job
|
||||||
|
if (map) map.dragPan.disable()
|
||||||
|
_mapGhost = document.createElement('div')
|
||||||
|
_mapGhost.className = 'sb-map-drag-ghost'
|
||||||
|
_mapGhost.textContent = job.subject
|
||||||
|
_mapGhost.style.cssText = `position:fixed;pointer-events:none;z-index:9999;left:${e.clientX + 14}px;top:${e.clientY + 14}px`
|
||||||
|
document.body.appendChild(_mapGhost)
|
||||||
|
document.addEventListener('mousemove', _onMapDragMove)
|
||||||
|
document.addEventListener('mouseup', _onMapDragEnd)
|
||||||
|
}
|
||||||
|
function _onMapDragMove (e) { if (_mapGhost) { _mapGhost.style.left = (e.clientX + 14) + 'px'; _mapGhost.style.top = (e.clientY + 14) + 'px' } }
|
||||||
|
function _onMapDragEnd (e) {
|
||||||
|
document.removeEventListener('mousemove', _onMapDragMove)
|
||||||
|
document.removeEventListener('mouseup', _onMapDragEnd)
|
||||||
|
if (_mapGhost) { _mapGhost.remove(); _mapGhost = null }
|
||||||
|
if (map) { map.getCanvas().style.cursor = ''; map.dragPan.enable() }
|
||||||
|
const job = mapDragJob.value; mapDragJob.value = null
|
||||||
|
if (!job) return
|
||||||
|
const els = document.elementsFromPoint(e.clientX, e.clientY)
|
||||||
|
const dateStr = localDateStr(periodStart.value)
|
||||||
|
function assignFromMap (tech) {
|
||||||
|
if (dragIsAssist.value) { dragIsAssist.value = false; return }
|
||||||
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
||||||
|
smartAssign(job, tech.id, dateStr)
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
const domTarget = els.find(el => el.dataset?.techId)
|
||||||
|
if (domTarget) { const tech = store.technicians.find(t => t.id === domTarget.dataset.techId); if (tech) assignFromMap(tech); return }
|
||||||
|
if (map && selectedTechId.value) {
|
||||||
|
const canvas = map.getCanvas(), rect = canvas.getBoundingClientRect()
|
||||||
|
if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
||||||
|
const tech = store.technicians.find(t => t.id === selectedTechId.value)
|
||||||
|
if (tech) assignFromMap(tech)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route computation ────────────────────────────────────────────────────────
|
||||||
|
async function computeDayRoute (tech, dateStr) {
|
||||||
|
const key = `${tech.id}||${dateStr}`
|
||||||
|
if (routeLegs.value[key] !== undefined) return
|
||||||
|
const points = []
|
||||||
|
if (tech.coords?.[0] && tech.coords?.[1]) points.push(`${tech.coords[0]},${tech.coords[1]}`)
|
||||||
|
const allJobs = [...tech.queue.filter(j => jobSpansDate(j, dateStr)), ...(tech.assistJobs || []).filter(j => jobSpansDate(j, dateStr))]
|
||||||
|
allJobs.forEach(j => { if (j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) points.push(`${j.coords[0]},${j.coords[1]}`) })
|
||||||
|
function setCache (legs, geom) {
|
||||||
|
routeLegs.value = { ...routeLegs.value, [key]: legs }
|
||||||
|
routeGeometry.value = { ...routeGeometry.value, [key]: geom }
|
||||||
|
}
|
||||||
|
if (points.length < 2) { setCache([], null); return }
|
||||||
|
try {
|
||||||
|
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${points.join(';')}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}`
|
||||||
|
const r = await fetch(url)
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const data = await r.json()
|
||||||
|
if (data.routes?.[0]) setCache(data.routes[0].legs.map(l => Math.round(l.duration / 60)), data.routes[0].geometry.coordinates)
|
||||||
|
else setCache([], null)
|
||||||
|
} catch (e) { console.warn('[route] fetch error', e); setCache([], null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw route ───────────────────────────────────────────────────────────────
|
||||||
|
function drawSelectedRoute () {
|
||||||
|
if (!map || !mapVisible.value) return
|
||||||
|
const src = map.getSource('sb-route'); if (!src) return
|
||||||
|
const empty = { type: 'FeatureCollection', features: [] }
|
||||||
|
if (currentView.value !== 'day') { src.setData(empty); return }
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
const features = []
|
||||||
|
const techs = selectedTechId.value ? filteredResources.value.filter(t => t.id === selectedTechId.value) : filteredResources.value
|
||||||
|
techs.forEach(tech => {
|
||||||
|
const coords = routeGeometry.value[`${tech.id}||${dayStr}`]
|
||||||
|
if (coords?.length) features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: TECH_COLORS[tech.colorIdx] } })
|
||||||
|
})
|
||||||
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
|
map.setPaintProperty('sb-route-halo', 'line-color', ['get', 'color'])
|
||||||
|
map.setPaintProperty('sb-route-line', 'line-color', ['get', 'color'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Select tech on board ─────────────────────────────────────────────────────
|
||||||
|
function selectTechOnBoard (tech) {
|
||||||
|
const wasSelected = selectedTechId.value === tech.id
|
||||||
|
selectedTechId.value = wasSelected ? null : tech.id
|
||||||
|
if (!wasSelected && currentView.value === 'day') {
|
||||||
|
if (!mapVisible.value) {
|
||||||
|
mapPanelW.value = Math.round(window.innerWidth * 0.5)
|
||||||
|
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
|
||||||
|
mapVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (map) { drawMapMarkers(); drawSelectedRoute() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Watchers ─────────────────────────────────────────────────────────────────
|
||||||
|
watch([selectedTechId, () => periodStart.value?.getTime(), currentView, routeGeometry], () => { if (map) { drawMapMarkers(); drawSelectedRoute() } })
|
||||||
|
watch(mapVisible, async v => {
|
||||||
|
if (v) {
|
||||||
|
if (map) { try { map.remove() } catch (_) {} map = null }
|
||||||
|
await nextTick(); await initMap()
|
||||||
|
if (map) {
|
||||||
|
const r = () => { if (!map) return; map.resize(); drawMapMarkers(); drawSelectedRoute() }
|
||||||
|
await nextTick(); r(); setTimeout(r, 100); setTimeout(r, 300); setTimeout(r, 600)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
|
||||||
|
if (map) { try { map.remove() } catch (_) {} map = null }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch([() => periodStart.value?.getTime(), filteredResources], () => {
|
||||||
|
if (currentView.value === 'day' && mapVisible.value && map) { drawMapMarkers(); drawSelectedRoute() }
|
||||||
|
})
|
||||||
|
watch(
|
||||||
|
() => store.technicians.map(t => t.gpsCoords),
|
||||||
|
() => { if (map) drawMapMarkers() },
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Lifecycle helpers ────────────────────────────────────────────────────────
|
||||||
|
function destroyMap () {
|
||||||
|
if (map) { map.remove(); map = null }
|
||||||
|
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
|
||||||
|
}
|
||||||
|
function loadMapboxCss () {
|
||||||
|
if (!document.getElementById('mapbox-css')) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getMap () { return map }
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, mapDragJob,
|
||||||
|
startGeoFix, cancelGeoFix, startMapResize, initMap,
|
||||||
|
drawMapMarkers, drawSelectedRoute, computeDayRoute,
|
||||||
|
selectTechOnBoard, destroyMap, loadMapboxCss, getMap,
|
||||||
|
}
|
||||||
|
}
|
||||||
209
apps/dispatch/src/composables/useScheduler.js
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
// ── Scheduling logic: timeline computation, route cache, job placement ───────
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate } from './useHelpers'
|
||||||
|
|
||||||
|
export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) {
|
||||||
|
const H_START = 7
|
||||||
|
const H_END = 20
|
||||||
|
|
||||||
|
// ── Route cache ────────────────────────────────────────────────────────────
|
||||||
|
const routeLegs = ref({})
|
||||||
|
const routeGeometry = ref({})
|
||||||
|
|
||||||
|
// ── Parent start position cache ────────────────────────────────────────────
|
||||||
|
let _parentStartCache = {}
|
||||||
|
|
||||||
|
function getParentStartH (job) {
|
||||||
|
if (!store.technicians.length) return job.startHour ?? 8
|
||||||
|
const key = `${job.assignedTech}||${job.id}`
|
||||||
|
if (_parentStartCache[key] !== undefined) return _parentStartCache[key]
|
||||||
|
const leadTech = store.technicians.find(t => t.id === job.assignedTech)
|
||||||
|
if (!leadTech) return job.startHour ?? 8
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
const leadJobs = sortJobsByTime(leadTech.queue.filter(j => getJobDate(j.id) === dayStr))
|
||||||
|
const cacheKey = `${leadTech.id}||${dayStr}`
|
||||||
|
const legMins = routeLegs.value[cacheKey]
|
||||||
|
const hasHome = !!(leadTech.coords?.[0] && leadTech.coords?.[1])
|
||||||
|
let cursor = 8, result = job.startHour ?? 8
|
||||||
|
leadJobs.forEach((j, idx) => {
|
||||||
|
const showTravel = idx > 0 || (idx === 0 && hasHome)
|
||||||
|
if (showTravel) {
|
||||||
|
const legIdx = hasHome ? idx : idx - 1
|
||||||
|
const routeMin = legMins?.[legIdx]
|
||||||
|
cursor += (routeMin != null ? routeMin : (parseFloat(j.legDur) > 0 ? parseFloat(j.legDur) : 20)) / 60
|
||||||
|
}
|
||||||
|
const pinnedH = j.startTime ? timeToH(j.startTime) : null
|
||||||
|
const startH = pinnedH ?? cursor
|
||||||
|
if (j.id === job.id) result = startH
|
||||||
|
cursor = startH + (parseFloat(j.duration) || 1)
|
||||||
|
})
|
||||||
|
_parentStartCache[key] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── All jobs for a tech on a date (primary + assists) ──────────────────────
|
||||||
|
function techAllJobsForDate (tech, dateStr) {
|
||||||
|
_parentStartCache = {}
|
||||||
|
const primary = tech.queue.filter(j => jobSpansDate(j, dateStr))
|
||||||
|
const assists = (tech.assistJobs || [])
|
||||||
|
.filter(j => jobSpansDate(j, dateStr))
|
||||||
|
.map(j => {
|
||||||
|
const a = j.assistants.find(x => x.techId === tech.id)
|
||||||
|
const parentH = getParentStartH(j)
|
||||||
|
return {
|
||||||
|
...j,
|
||||||
|
duration: a?.duration || j.duration,
|
||||||
|
startTime: hToTime(parentH),
|
||||||
|
startHour: parentH,
|
||||||
|
_isAssist: true,
|
||||||
|
_assistPinned: !!a?.pinned,
|
||||||
|
_assistNote: a?.note || '',
|
||||||
|
_parentJob: j,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return sortJobsByTime([...primary, ...assists])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Day view: schedule blocks with pinned anchors + auto-flow ──────────────
|
||||||
|
function techDayJobsWithTravel (tech) {
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
const cacheKey = `${tech.id}||${dayStr}`
|
||||||
|
const legMins = routeLegs.value[cacheKey]
|
||||||
|
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1])
|
||||||
|
const allJobs = techAllJobsForDate(tech, dayStr)
|
||||||
|
|
||||||
|
const flowEntries = []
|
||||||
|
const floatingEntries = []
|
||||||
|
allJobs.forEach(job => {
|
||||||
|
const isAssist = !!job._isAssist
|
||||||
|
const dur = parseFloat(job.duration) || 1
|
||||||
|
const isPinned = isAssist ? !!job._assistPinned : !!getJobTime(job.id)
|
||||||
|
const pinH = isAssist ? job.startHour : (getJobTime(job.id) ? timeToH(getJobTime(job.id)) : null)
|
||||||
|
const entry = { job, dur, isAssist, isPinned, pinH }
|
||||||
|
if (isAssist && !job._assistPinned) floatingEntries.push(entry)
|
||||||
|
else flowEntries.push(entry)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pinnedAnchors = flowEntries.filter(e => e.isPinned).map(e => ({ start: e.pinH, end: e.pinH + e.dur }))
|
||||||
|
const placed = []
|
||||||
|
const occupied = pinnedAnchors.map(a => ({ ...a }))
|
||||||
|
const sortedFlow = [...flowEntries].sort((a, b) => {
|
||||||
|
if (a.isPinned && b.isPinned) return a.pinH - b.pinH
|
||||||
|
if (a.isPinned) return -1
|
||||||
|
if (b.isPinned) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
sortedFlow.filter(e => e.isPinned).forEach(e => placed.push({ entry: e, startH: e.pinH }))
|
||||||
|
|
||||||
|
let cursor = 8, flowIdx = 0
|
||||||
|
sortedFlow.filter(e => !e.isPinned).forEach(e => {
|
||||||
|
const legIdx = hasHome ? flowIdx : flowIdx - 1
|
||||||
|
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
|
||||||
|
const travelH = (routeMin != null ? routeMin : (parseFloat(e.job.legDur) > 0 ? parseFloat(e.job.legDur) : 20)) / 60
|
||||||
|
let startH = cursor + (flowIdx > 0 || hasHome ? travelH : 0)
|
||||||
|
let safe = false
|
||||||
|
while (!safe) {
|
||||||
|
const endH = startH + e.dur
|
||||||
|
const overlap = occupied.find(o => startH < o.end && endH > o.start)
|
||||||
|
if (overlap) startH = overlap.end + travelH
|
||||||
|
else safe = true
|
||||||
|
}
|
||||||
|
placed.push({ entry: e, startH })
|
||||||
|
occupied.push({ start: startH, end: startH + e.dur })
|
||||||
|
cursor = startH + e.dur
|
||||||
|
flowIdx++
|
||||||
|
})
|
||||||
|
|
||||||
|
placed.sort((a, b) => a.startH - b.startH)
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
let prevEndH = null
|
||||||
|
placed.forEach((p, pIdx) => {
|
||||||
|
const { entry, startH } = p
|
||||||
|
const { job, dur, isAssist, isPinned } = entry
|
||||||
|
const realJob = isAssist ? job._parentJob : job
|
||||||
|
const travelStart = prevEndH ?? (hasHome ? 8 : null)
|
||||||
|
if (travelStart != null && startH > travelStart + 0.01) {
|
||||||
|
const gapH = startH - travelStart
|
||||||
|
const legIdx = hasHome ? pIdx : pIdx - 1
|
||||||
|
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
|
||||||
|
const fromRoute = routeMin != null
|
||||||
|
result.push({
|
||||||
|
type: 'travel', job: realJob, travelMin: fromRoute ? routeMin : Math.round(gapH * 60), fromRoute, isAssist: false,
|
||||||
|
style: { left: (travelStart - H_START) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' },
|
||||||
|
color: jobColorFn(realJob),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const jLeft = (startH - H_START) * pxPerHr.value
|
||||||
|
const jWidth = Math.max(18, dur * pxPerHr.value)
|
||||||
|
result.push({
|
||||||
|
type: isAssist ? 'assist' : 'job', job: realJob,
|
||||||
|
pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist,
|
||||||
|
assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null,
|
||||||
|
assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null,
|
||||||
|
style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' },
|
||||||
|
})
|
||||||
|
prevEndH = startH + dur
|
||||||
|
})
|
||||||
|
|
||||||
|
floatingEntries.forEach(entry => {
|
||||||
|
const { job, dur } = entry
|
||||||
|
const startH = job.startHour ?? 8
|
||||||
|
result.push({
|
||||||
|
type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true,
|
||||||
|
assistPinned: false, assistDur: dur, assistNote: job._assistNote, assistTechId: tech.id,
|
||||||
|
style: { left: (startH - H_START) * pxPerHr.value + 'px', width: Math.max(18, dur * pxPerHr.value) + 'px', top: '52%', bottom: '4px', position: 'absolute' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Week view helpers ──────────────────────────────────────────────────────
|
||||||
|
function techBookingsByDay (tech) {
|
||||||
|
return dayColumns.value.map(d => {
|
||||||
|
const ds = localDateStr(d)
|
||||||
|
const primary = tech.queue.filter(j => jobSpansDate(j, ds))
|
||||||
|
const assists = (tech.assistJobs || [])
|
||||||
|
.filter(j => jobSpansDate(j, ds) && j.assistants.find(a => a.techId === tech.id)?.pinned)
|
||||||
|
.map(j => ({ ...j, _isAssistChip: true, _assistDur: j.assistants.find(a => a.techId === tech.id)?.duration || j.duration }))
|
||||||
|
return { day: d, dateStr: ds, jobs: [...primary, ...assists] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodLoadH (tech) {
|
||||||
|
const dateSet = new Set(dayColumns.value.map(d => localDateStr(d)))
|
||||||
|
let total = tech.queue.reduce((sum, j) => {
|
||||||
|
const ds = getJobDate(j.id)
|
||||||
|
return ds && dateSet.has(ds) ? sum + (parseFloat(j.duration) || 0) : sum
|
||||||
|
}, 0)
|
||||||
|
;(tech.assistJobs || []).forEach(j => {
|
||||||
|
const ds = getJobDate(j.id)
|
||||||
|
if (ds && dateSet.has(ds)) {
|
||||||
|
const a = j.assistants.find(x => x.techId === tech.id)
|
||||||
|
if (a?.pinned) total += parseFloat(a?.duration || j.duration) || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
function techsActiveOnDay (dateStr, resources) {
|
||||||
|
return resources.filter(tech =>
|
||||||
|
tech.queue.some(j => jobSpansDate(j, dateStr)) ||
|
||||||
|
(tech.assistJobs || []).some(j => jobSpansDate(j, dateStr) && j.assistants.find(a => a.techId === tech.id)?.pinned)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayJobCount (dateStr, resources) {
|
||||||
|
const jobIds = new Set()
|
||||||
|
resources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr)).forEach(j => jobIds.add(j.id)))
|
||||||
|
return jobIds.size
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
H_START, H_END, routeLegs, routeGeometry,
|
||||||
|
techAllJobsForDate, techDayJobsWithTravel,
|
||||||
|
techBookingsByDay, periodLoadH, techsActiveOnDay, dayJobCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
172
apps/dispatch/src/composables/useSelection.js
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
// ── Selection composable: lasso, multi-select, hover linking, batch ops ───────
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { localDateStr } from './useHelpers'
|
||||||
|
|
||||||
|
export function useSelection (deps) {
|
||||||
|
const { store, periodStart, smartAssign, invalidateRoutes, fullUnassign } = deps
|
||||||
|
|
||||||
|
const hoveredJobId = ref(null)
|
||||||
|
const selectedJob = ref(null) // { job, techId, isAssist?, assistTechId? }
|
||||||
|
const multiSelect = ref([]) // [{ job, techId, isAssist?, assistTechId? }]
|
||||||
|
|
||||||
|
// ── Select / toggle ─────────────────────────────────────────────────────────
|
||||||
|
function selectJob (job, techId, isAssist = false, assistTechId = null, event = null, rightPanel = null) {
|
||||||
|
const entry = { job, techId, isAssist, assistTechId }
|
||||||
|
const isMulti = event && (event.ctrlKey || event.metaKey)
|
||||||
|
if (isMulti) {
|
||||||
|
const idx = multiSelect.value.findIndex(s => s.job.id === job.id && s.isAssist === isAssist)
|
||||||
|
if (idx >= 0) multiSelect.value.splice(idx, 1)
|
||||||
|
else multiSelect.value.push(entry)
|
||||||
|
selectedJob.value = entry
|
||||||
|
} else {
|
||||||
|
multiSelect.value = []
|
||||||
|
const same = selectedJob.value?.job?.id === job.id && selectedJob.value?.isAssist === isAssist && selectedJob.value?.assistTechId === assistTechId
|
||||||
|
selectedJob.value = same ? null : entry
|
||||||
|
if (!same && rightPanel !== undefined) {
|
||||||
|
const tech = store.technicians.find(t => t.id === (techId || job.assignedTech))
|
||||||
|
if (rightPanel !== null && typeof rightPanel === 'object' && 'value' in rightPanel) {
|
||||||
|
rightPanel.value = { mode: 'details', data: { job, tech: tech || null } }
|
||||||
|
}
|
||||||
|
} else if (rightPanel !== null && typeof rightPanel === 'object' && 'value' in rightPanel) {
|
||||||
|
rightPanel.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJobMultiSelected (jobId, isAssist = false) {
|
||||||
|
return multiSelect.value.some(s => s.job.id === jobId && s.isAssist === isAssist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch ops (grouped undo) ──────────────────────────────────────────────────
|
||||||
|
function batchUnassign (pushUndo) {
|
||||||
|
if (!multiSelect.value.length) return
|
||||||
|
// Snapshot all jobs before unassign — single undo entry
|
||||||
|
const assignments = multiSelect.value.filter(s => !s.isAssist).map(s => ({
|
||||||
|
jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder,
|
||||||
|
scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])]
|
||||||
|
}))
|
||||||
|
const prevQueues = {}
|
||||||
|
store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] })
|
||||||
|
|
||||||
|
multiSelect.value.forEach(s => {
|
||||||
|
if (s.isAssist && s.assistTechId) store.removeAssistant(s.job.id, s.assistTechId)
|
||||||
|
else store.fullUnassign(s.job.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pushUndo && assignments.length) {
|
||||||
|
pushUndo({ type: 'batchAssign', assignments, prevQueues })
|
||||||
|
}
|
||||||
|
multiSelect.value = []; selectedJob.value = null
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchMoveTo (techId, dayStr, pushUndo) {
|
||||||
|
if (!multiSelect.value.length) return
|
||||||
|
const day = dayStr || localDateStr(periodStart.value)
|
||||||
|
const jobs = multiSelect.value.filter(s => !s.isAssist)
|
||||||
|
// Snapshot for grouped undo
|
||||||
|
const assignments = jobs.map(s => ({
|
||||||
|
jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder,
|
||||||
|
scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])]
|
||||||
|
}))
|
||||||
|
const prevQueues = {}
|
||||||
|
store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] })
|
||||||
|
|
||||||
|
jobs.forEach(s => smartAssign(s.job, techId, day))
|
||||||
|
|
||||||
|
if (pushUndo && assignments.length) {
|
||||||
|
pushUndo({ type: 'batchAssign', assignments, prevQueues })
|
||||||
|
}
|
||||||
|
multiSelect.value = []; selectedJob.value = null
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lasso ─────────────────────────────────────────────────────────────────────
|
||||||
|
const lasso = ref(null)
|
||||||
|
const boardScroll = ref(null)
|
||||||
|
|
||||||
|
const lassoStyle = computed(() => {
|
||||||
|
if (!lasso.value) return {}
|
||||||
|
const l = lasso.value
|
||||||
|
return {
|
||||||
|
left: Math.min(l.x1, l.x2) + 'px', top: Math.min(l.y1, l.y2) + 'px',
|
||||||
|
width: Math.abs(l.x2 - l.x1) + 'px', height: Math.abs(l.y2 - l.y1) + 'px',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function startLasso (e) {
|
||||||
|
if (e.target.closest('.sb-block, .sb-chip, .sb-res-cell, .sb-travel-trail, button, input, select, a')) return
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
if (selectedJob.value || multiSelect.value.length) {
|
||||||
|
selectedJob.value = null; multiSelect.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rect = boardScroll.value.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left + boardScroll.value.scrollLeft
|
||||||
|
const y = e.clientY - rect.top + boardScroll.value.scrollTop
|
||||||
|
lasso.value = { x1: x, y1: y, x2: x, y2: y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLasso (e) {
|
||||||
|
if (!lasso.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
const rect = boardScroll.value.getBoundingClientRect()
|
||||||
|
lasso.value.x2 = e.clientX - rect.left + boardScroll.value.scrollLeft
|
||||||
|
lasso.value.y2 = e.clientY - rect.top + boardScroll.value.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
function endLasso () {
|
||||||
|
if (!lasso.value) return
|
||||||
|
const l = lasso.value
|
||||||
|
const w = Math.abs(l.x2 - l.x1), h = Math.abs(l.y2 - l.y1)
|
||||||
|
if (w > 10 && h > 10) {
|
||||||
|
const boardRect = boardScroll.value.getBoundingClientRect()
|
||||||
|
const lassoLeft = Math.min(l.x1, l.x2) - boardScroll.value.scrollLeft + boardRect.left
|
||||||
|
const lassoTop = Math.min(l.y1, l.y2) - boardScroll.value.scrollTop + boardRect.top
|
||||||
|
const lassoRight = lassoLeft + w, lassoBottom = lassoTop + h
|
||||||
|
const blocks = boardScroll.value.querySelectorAll('.sb-block[data-job-id], .sb-chip')
|
||||||
|
const selected = []
|
||||||
|
blocks.forEach(el => {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
if (r.right > lassoLeft && r.left < lassoRight && r.bottom > lassoTop && r.top < lassoBottom) {
|
||||||
|
const jobId = el.dataset?.jobId
|
||||||
|
if (jobId) {
|
||||||
|
const job = store.jobs.find(j => j.id === jobId)
|
||||||
|
if (job) selected.push({ job, techId: job.assignedTech, isAssist: false, assistTechId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (selected.length) {
|
||||||
|
multiSelect.value = selected
|
||||||
|
if (selected.length === 1) selectedJob.value = selected[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lasso.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hover linking helpers ─────────────────────────────────────────────────────
|
||||||
|
function techHasLinkedJob (tech) {
|
||||||
|
const hId = hoveredJobId.value, sId = selectedJob.value?.job?.id
|
||||||
|
if (hId && (tech.assistJobs || []).some(j => j.id === hId)) return true
|
||||||
|
if (hId && tech.queue.some(j => j.id === hId)) return true
|
||||||
|
if (sId && !selectedJob.value?.isAssist && (tech.assistJobs || []).some(j => j.id === sId)) return true
|
||||||
|
if (sId && selectedJob.value?.isAssist && tech.queue.some(j => j.id === sId)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function techIsHovered (tech) {
|
||||||
|
const hId = hoveredJobId.value
|
||||||
|
if (!hId) return false
|
||||||
|
const job = tech.queue.find(j => j.id === hId)
|
||||||
|
return job && job.assistants?.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hoveredJobId, selectedJob, multiSelect,
|
||||||
|
selectJob, isJobMultiSelected, batchUnassign, batchMoveTo,
|
||||||
|
lasso, boardScroll, lassoStyle, startLasso, moveLasso, endLasso,
|
||||||
|
techHasLinkedJob, techIsHovered,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/dispatch/src/composables/useUndo.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// ── Undo stack composable ────────────────────────────────────────────────────
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { updateJob } from 'src/api/dispatch'
|
||||||
|
import { serializeAssistants } from './useHelpers'
|
||||||
|
|
||||||
|
export function useUndo (store, invalidateRoutes) {
|
||||||
|
const undoStack = ref([])
|
||||||
|
|
||||||
|
function pushUndo (action) {
|
||||||
|
undoStack.value.push(action)
|
||||||
|
if (undoStack.value.length > 30) undoStack.value.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore a single job to its previous state (unassign from current tech, re-assign if it had one)
|
||||||
|
function _restoreJob (prev) {
|
||||||
|
const job = store.jobs.find(j => j.id === prev.jobId)
|
||||||
|
if (!job) return
|
||||||
|
// Remove from all tech queues first
|
||||||
|
store.technicians.forEach(t => { t.queue = t.queue.filter(q => q.id !== prev.jobId) })
|
||||||
|
if (prev.techId) {
|
||||||
|
// Was assigned before — re-assign
|
||||||
|
store.assignJobToTech(prev.jobId, prev.techId, prev.routeOrder, prev.scheduledDate)
|
||||||
|
} else {
|
||||||
|
// Was unassigned before — just mark as open
|
||||||
|
job.assignedTech = null
|
||||||
|
job.status = 'open'
|
||||||
|
job.scheduledDate = prev.scheduledDate || null
|
||||||
|
updateJob(job.name || job.id, { assigned_tech: null, status: 'open', scheduled_date: prev.scheduledDate || '' }).catch(() => {})
|
||||||
|
}
|
||||||
|
if (prev.assistants?.length) {
|
||||||
|
job.assistants = prev.assistants
|
||||||
|
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performUndo () {
|
||||||
|
const action = undoStack.value.pop()
|
||||||
|
if (!action) return
|
||||||
|
|
||||||
|
if (action.type === 'removeAssistant') {
|
||||||
|
store.addAssistant(action.jobId, action.techId)
|
||||||
|
nextTick(() => {
|
||||||
|
const job = store.jobs.find(j => j.id === action.jobId)
|
||||||
|
const a = job?.assistants.find(x => x.techId === action.techId)
|
||||||
|
if (a) { a.duration = action.duration; a.note = action.note }
|
||||||
|
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
} else if (action.type === 'optimizeRoute') {
|
||||||
|
const tech = store.technicians.find(t => t.id === action.techId)
|
||||||
|
if (tech) {
|
||||||
|
tech.queue = action.prevQueue
|
||||||
|
action.prevQueue.forEach((j, i) => { j.routeOrder = i })
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (action.type === 'autoDistribute') {
|
||||||
|
action.assignments.forEach(a => _restoreJob(a))
|
||||||
|
if (action.prevQueues) {
|
||||||
|
store.technicians.forEach(t => {
|
||||||
|
if (action.prevQueues[t.id]) t.queue = action.prevQueues[t.id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (action.type === 'batchAssign') {
|
||||||
|
// Undo a multi-select drag — restore each job to previous state
|
||||||
|
action.assignments.forEach(a => _restoreJob(a))
|
||||||
|
|
||||||
|
} else if (action.type === 'unassignJob') {
|
||||||
|
_restoreJob(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild assistJobs on all techs
|
||||||
|
store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) })
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { undoStack, pushUndo, performUndo }
|
||||||
|
}
|
||||||
26
apps/dispatch/src/config/erpnext.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// ── ERPNext connection config ────────────────────────────────────────────────
|
||||||
|
// To host the app separately from ERPNext (e.g. Nginx, Vercel):
|
||||||
|
// - Set BASE_URL to 'https://your-erpnext.example.com'
|
||||||
|
// - Add CORS + session/JWT config on the ERPNext side
|
||||||
|
// - Update api/auth.js if switching from session cookie to JWT
|
||||||
|
// For same-origin (ERPNext serves the app): keep BASE_URL as empty string.
|
||||||
|
|
||||||
|
// In production, /api/ is proxied to ERPNext via nginx (same-origin, no CORS)
|
||||||
|
// In dev (localhost), calls go directly to ERPNext
|
||||||
|
export const BASE_URL = window.location.hostname === 'localhost' ? 'https://erp.gigafibre.ca' : ''
|
||||||
|
|
||||||
|
// Mapbox public token — safe to expose (scope-limited in Mapbox dashboard)
|
||||||
|
export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
|
||||||
|
|
||||||
|
export const TECH_COLORS = [
|
||||||
|
'#6366f1', // Indigo
|
||||||
|
'#10b981', // Emerald
|
||||||
|
'#f59e0b', // Amber
|
||||||
|
'#8b5cf6', // Violet
|
||||||
|
'#06b6d4', // Cyan
|
||||||
|
'#f43f5e', // Rose
|
||||||
|
'#f97316', // Orange
|
||||||
|
'#14b8a6', // Teal
|
||||||
|
'#d946ef', // Fuchsia
|
||||||
|
'#3b82f6', // Blue
|
||||||
|
]
|
||||||
41
apps/dispatch/src/css/app.scss
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// ── Global CSS variables ────────────────────────────────────────────────────
|
||||||
|
// Shared between DispatchPage and MobilePage.
|
||||||
|
// To add a new theme: duplicate the :root block with a body class selector.
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// Dark theme (default for dispatch desktop)
|
||||||
|
--bg: #0b0f1a;
|
||||||
|
--sidebar-bg: rgba(15, 23, 42, 0.9);
|
||||||
|
--card-bg: rgba(30, 41, 59, 0.5);
|
||||||
|
--card-hover: rgba(51, 65, 85, 0.6);
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-accent: rgba(99, 102, 241, 0.3);
|
||||||
|
--text-primary: #f8fafc;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||||
|
--green: #10b981;
|
||||||
|
--green-glow: rgba(16, 185, 129, 0.2);
|
||||||
|
--orange: #f59e0b;
|
||||||
|
--red: #f43f5e;
|
||||||
|
--card-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--sidebar-bg: #ffffff;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-hover: #f1f5f9;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--border-accent: #cbd5e1;
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-glow: rgba(79, 70, 229, 0.1);
|
||||||
|
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
// Quasar resets some of these — keep them consistent
|
||||||
|
html, body { height: 100%; }
|
||||||
194
apps/dispatch/src/modules/dispatch/components/BottomPanel.vue
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, inject } from 'vue'
|
||||||
|
import { fmtDur, shortAddr, prioLabel, prioClass, prioColor, dayLoadColor, ICON } from 'src/composables/useHelpers'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: Boolean,
|
||||||
|
height: Number,
|
||||||
|
groups: Array,
|
||||||
|
unscheduledCount: Number,
|
||||||
|
selected: Object, // Set
|
||||||
|
dropActive: Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:open', 'update:height', 'resize-start',
|
||||||
|
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
|
||||||
|
'auto-distribute', 'open-criteria',
|
||||||
|
'row-click', 'row-dblclick', 'row-dragstart',
|
||||||
|
'drop-unassign', 'lasso-select', 'deselect-all',
|
||||||
|
])
|
||||||
|
|
||||||
|
const store = inject('store')
|
||||||
|
const TECH_COLORS = inject('TECH_COLORS')
|
||||||
|
const jobColor = inject('jobColor')
|
||||||
|
const btColW = inject('btColW')
|
||||||
|
const startColResize = inject('startColResize')
|
||||||
|
|
||||||
|
// ── Lasso selection ───────────────────────────────────────────────────────────
|
||||||
|
const btLasso = ref(null)
|
||||||
|
const btScrollRef = ref(null)
|
||||||
|
let btLassoMoved = false
|
||||||
|
|
||||||
|
function btLassoStart (e) {
|
||||||
|
if (e.target.closest('button, input, .sb-bt-checkbox, a, .sb-col-resize, .sb-bottom-hdr, .sb-bottom-resize')) return
|
||||||
|
if (e.button !== 0) return
|
||||||
|
const scroll = btScrollRef.value
|
||||||
|
if (!scroll) return
|
||||||
|
|
||||||
|
// On a job row — don't start lasso, let drag handle it
|
||||||
|
const row = e.target.closest('.sb-bottom-row')
|
||||||
|
if (row) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
btLassoMoved = false
|
||||||
|
const rect = scroll.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left + scroll.scrollLeft
|
||||||
|
const y = e.clientY - rect.top + scroll.scrollTop
|
||||||
|
btLasso.value = { x1: x, y1: y, x2: x, y2: y }
|
||||||
|
document.addEventListener('mousemove', btLassoMove)
|
||||||
|
document.addEventListener('mouseup', btLassoEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function btLassoMove (e) {
|
||||||
|
if (!btLasso.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
btLassoMoved = true
|
||||||
|
const scroll = btScrollRef.value
|
||||||
|
const rect = scroll.getBoundingClientRect()
|
||||||
|
btLasso.value.x2 = e.clientX - rect.left + scroll.scrollLeft
|
||||||
|
btLasso.value.y2 = e.clientY - rect.top + scroll.scrollTop
|
||||||
|
|
||||||
|
// Live selection as lasso moves
|
||||||
|
const l = btLasso.value
|
||||||
|
const h = Math.abs(l.y2 - l.y1)
|
||||||
|
if (h > 8) {
|
||||||
|
const scrollRect = scroll.getBoundingClientRect()
|
||||||
|
const lassoTop = Math.min(l.y1, l.y2) - scroll.scrollTop + scrollRect.top
|
||||||
|
const lassoBottom = lassoTop + h
|
||||||
|
const rows = scroll.querySelectorAll('.sb-bottom-row')
|
||||||
|
const ids = []
|
||||||
|
rows.forEach(row => {
|
||||||
|
const r = row.getBoundingClientRect()
|
||||||
|
if (r.bottom > lassoTop && r.top < lassoBottom) {
|
||||||
|
const jobId = row.dataset?.jobId
|
||||||
|
if (jobId) ids.push(jobId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (ids.length) emit('lasso-select', ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function btLassoEnd () {
|
||||||
|
document.removeEventListener('mousemove', btLassoMove)
|
||||||
|
document.removeEventListener('mouseup', btLassoEnd)
|
||||||
|
if (!btLasso.value) return
|
||||||
|
|
||||||
|
// If no movement = click on empty space = clear selection
|
||||||
|
if (!btLassoMoved) {
|
||||||
|
emit('deselect-all')
|
||||||
|
}
|
||||||
|
|
||||||
|
btLasso.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="open" class="sb-bottom-panel" :style="'height:'+height+'px'">
|
||||||
|
<div class="sb-bottom-resize" @mousedown.prevent="emit('resize-start', $event)"></div>
|
||||||
|
<div class="sb-bottom-hdr">
|
||||||
|
<span class="sb-bottom-title">
|
||||||
|
Jobs non assignées
|
||||||
|
<span class="sbf-count">{{ unscheduledCount }}</span>
|
||||||
|
</span>
|
||||||
|
<button v-if="unscheduledCount" class="sbf-auto-btn" @click="emit('auto-distribute')" title="Répartir automatiquement">⚡ Répartir auto</button>
|
||||||
|
<button class="sbf-auto-btn" style="border-color:rgba(255,255,255,0.12)" @click="emit('open-criteria')" title="Critères de dispatch">⚙ Critères</button>
|
||||||
|
<!-- Batch assign bar -->
|
||||||
|
<template v-if="selected.size">
|
||||||
|
<span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
|
||||||
|
<span class="sb-bottom-sel-lbl">→</span>
|
||||||
|
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech"
|
||||||
|
:style="'border-color:'+TECH_COLORS[t.colorIdx]" :title="t.fullName"
|
||||||
|
@click="emit('batch-assign', t.id)">
|
||||||
|
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
||||||
|
</button>
|
||||||
|
<button class="sb-bottom-sel-clear" @click="emit('clear-select')">✕</button>
|
||||||
|
</template>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button v-if="unscheduledCount" class="sb-bottom-sel-all" @click="emit('select-all')" title="Tout sélectionner">☐ Tout</button>
|
||||||
|
<button class="sb-bottom-close" @click="emit('update:open', false)">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="sb-bottom-body"
|
||||||
|
:class="{ 'sbf-drop-active': dropActive }"
|
||||||
|
@dragover.prevent="$emit('drop-unassign', $event, 'over')"
|
||||||
|
@dragleave="$emit('drop-unassign', $event, 'leave')"
|
||||||
|
@drop="$emit('drop-unassign', $event, 'drop')">
|
||||||
|
<div v-if="dropActive" class="sbf-drop-hint" style="margin:4px">↩ Désaffecter ici</div>
|
||||||
|
<table class="sb-bottom-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sb-bt-chk" style="width:28px"></th>
|
||||||
|
<th class="sb-bt-prio" style="width:12px"></th>
|
||||||
|
<th class="sb-bt-name" :style="'width:'+btColW('name',200)"><span>Nom</span><div class="sb-col-resize" @mousedown="startColResize($event,'name')"></div></th>
|
||||||
|
<th class="sb-bt-addr" :style="'width:'+btColW('addr',180)"><span>Adresse</span><div class="sb-col-resize" @mousedown="startColResize($event,'addr')"></div></th>
|
||||||
|
<th class="sb-bt-dur" :style="'width:'+btColW('dur',130)"><span>Durée</span><div class="sb-col-resize" @mousedown="startColResize($event,'dur')"></div></th>
|
||||||
|
<th class="sb-bt-prio-lbl" style="width:70px">Priorité</th>
|
||||||
|
<th class="sb-bt-tags">Skills / Tags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
<div class="sb-bottom-scroll" ref="btScrollRef" @mousedown="btLassoStart" style="position:relative">
|
||||||
|
<template v-for="group in groups" :key="group.date||'nodate'">
|
||||||
|
<div class="sb-bottom-date-sep">
|
||||||
|
<span class="sb-bottom-date-label">{{ group.label }}</span>
|
||||||
|
<span class="sb-bottom-date-count">{{ group.jobs.length }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="sb-bottom-table">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="job in group.jobs" :key="job.id"
|
||||||
|
class="sb-bottom-row" :class="{ 'sb-bottom-row-sel': selected.has(job.id) }"
|
||||||
|
:data-job-id="job.id"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="emit('row-dragstart', $event, job, selected.has(job.id) && selected.size > 1)"
|
||||||
|
@click="emit('row-click', job, $event)"
|
||||||
|
@dblclick.stop="emit('row-dblclick', job)">
|
||||||
|
<td class="sb-bt-chk" style="width:28px" @click.stop="emit('toggle-select', job.id, $event)">
|
||||||
|
<span class="sb-bt-checkbox" :class="{ checked: selected.has(job.id) }"></span>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-prio" style="width:12px">
|
||||||
|
<span class="sb-bt-prio-dot" :style="'background:'+prioColor(job.priority)" :title="prioLabel(job.priority)"></span>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-name" :style="'width:'+btColW('name',200)">
|
||||||
|
<span class="sb-bt-name-text">{{ job.subject }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-addr" :style="'width:'+btColW('addr',180)">{{ shortAddr(job.address) || '—' }}</td>
|
||||||
|
<td class="sb-bt-dur" :style="'width:'+btColW('dur',130)">
|
||||||
|
<div class="sb-bt-dur-wrap">
|
||||||
|
<div class="sb-bt-dur-bar">
|
||||||
|
<div class="sb-bt-dur-fill" :style="{ width: Math.min(100,(parseFloat(job.duration)||0)/8*100)+'%', background: dayLoadColor((parseFloat(job.duration)||0)/8) }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="sb-bt-dur-lbl">{{ fmtDur(job.duration) }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-prio-lbl" style="width:70px">
|
||||||
|
<span :class="prioClass(job.priority)" class="sb-bt-prio-tag">{{ prioLabel(job.priority) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="sb-bt-tags">
|
||||||
|
<span v-for="t in (job.tags||[])" :key="t" class="sb-bt-skill-chip">{{ t }}</span>
|
||||||
|
<span v-if="!(job.tags||[]).length" class="sb-bt-no-tag">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<div v-if="!unscheduledCount" class="sbf-empty" style="padding:1rem;text-align:center">Aucune job non assignée</div>
|
||||||
|
<div v-if="btLasso" class="sb-bt-lasso" :style="{
|
||||||
|
left: Math.min(btLasso.x1, btLasso.x2) + 'px',
|
||||||
|
top: Math.min(btLasso.y1, btLasso.y2) + 'px',
|
||||||
|
width: Math.abs(btLasso.x2 - btLasso.x1) + 'px',
|
||||||
|
height: Math.abs(btLasso.y2 - btLasso.y1) + 'px'
|
||||||
|
}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { ICON } from 'src/composables/useHelpers'
|
||||||
|
import TagInput from 'src/components/TagInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ modelValue: Object }) // { job, subject, address, note, duration, priority, tags, latitude, longitude }
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
|
||||||
|
|
||||||
|
const store = inject('store')
|
||||||
|
const MAPBOX_TOKEN = inject('MAPBOX_TOKEN')
|
||||||
|
const getTagColor = inject('getTagColor')
|
||||||
|
const onCreateTag = inject('onCreateTag')
|
||||||
|
const searchAddr = inject('searchAddr')
|
||||||
|
const addrResults = inject('addrResults')
|
||||||
|
const selectAddr = inject('selectAddr')
|
||||||
|
|
||||||
|
function close () { emit('update:modelValue', null); emit('cancel') }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="modelValue" class="sb-overlay" @click.self="close">
|
||||||
|
<div class="sb-modal sb-modal-wo">
|
||||||
|
<div class="sb-modal-hdr">
|
||||||
|
<span>✏ Modifier la job</span>
|
||||||
|
<button class="sb-rp-close" @click="close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-body sb-wo-body">
|
||||||
|
<div class="sb-wo-form">
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Titre</label>
|
||||||
|
<input class="sb-form-input" v-model="modelValue.subject" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Adresse</label>
|
||||||
|
<div class="sb-addr-wrap">
|
||||||
|
<input class="sb-form-input" v-model="modelValue.address" placeholder="123 rue Exemple"
|
||||||
|
@input="searchAddr(modelValue.address)" @blur="setTimeout(()=>addrResults.length=0,200)" autocomplete="off" />
|
||||||
|
<div v-if="addrResults.length" class="sb-addr-dropdown">
|
||||||
|
<div v-for="a in addrResults" :key="a.address_full" class="sb-addr-item"
|
||||||
|
@mousedown.prevent="selectAddr(a, modelValue)">
|
||||||
|
<strong>{{ a.address_full }}</strong>
|
||||||
|
<span v-if="a.code_postal" class="sb-addr-cp">{{ a.code_postal }}</span>
|
||||||
|
<span v-if="a.ville" class="sb-addr-city">{{ a.ville }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Note</label>
|
||||||
|
<textarea class="sb-form-input" v-model="modelValue.note" rows="2" placeholder="Ex: chien dangereux, sonner 2x…" style="resize:vertical"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Durée (h)</label>
|
||||||
|
<input type="number" class="sb-form-input" v-model.number="modelValue.duration" min="0.25" max="24" step="0.25" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Priorité</label>
|
||||||
|
<select class="sb-form-sel" v-model="modelValue.priority">
|
||||||
|
<option value="low">Basse</option>
|
||||||
|
<option value="medium">Moyenne</option>
|
||||||
|
<option value="high">Haute</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Tags / Skills</label>
|
||||||
|
<TagInput v-model="modelValue.tags" :all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="modelValue.latitude" class="sb-wo-minimap">
|
||||||
|
<img :src="'https://api.mapbox.com/styles/v1/mapbox/dark-v11/static/pin-s+6366f1('+modelValue.longitude+','+modelValue.latitude+')/'+modelValue.longitude+','+modelValue.latitude+',14,0/280x280@2x?access_token='+MAPBOX_TOKEN"
|
||||||
|
alt="Carte" class="sb-minimap-img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-ftr">
|
||||||
|
<button class="sbf-primary-btn" @click="emit('confirm')">✓ Enregistrer</button>
|
||||||
|
<button class="sb-rp-btn" @click="close">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
50
apps/dispatch/src/modules/dispatch/components/MapPanel.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { SVC_COLORS } from 'src/composables/useHelpers'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: Boolean,
|
||||||
|
panelW: Number,
|
||||||
|
selectedTechId: String,
|
||||||
|
geoFixJob: Object,
|
||||||
|
mapContainer: Object, // template ref
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'close', 'resize-start', 'cancel-geofix',
|
||||||
|
])
|
||||||
|
|
||||||
|
const store = inject('store')
|
||||||
|
const TECH_COLORS = inject('TECH_COLORS')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="visible">
|
||||||
|
<div class="sb-map-backdrop" @click="emit('close')"></div>
|
||||||
|
<div class="sb-map-panel" @click.stop="()=>{}" :style="`width:${panelW}px;min-width:${panelW}px`">
|
||||||
|
<div class="sb-map-resize-handle" @mousedown.prevent="emit('resize-start', $event)"></div>
|
||||||
|
<div class="sb-map-bar" :class="{ 'sb-map-bar-geofix': geoFixJob }">
|
||||||
|
<span class="sb-map-title">Carte</span>
|
||||||
|
<template v-if="geoFixJob">
|
||||||
|
<span class="sb-geofix-hint">📍 Cliquer sur la carte pour placer <strong>{{ geoFixJob.subject }}</strong></span>
|
||||||
|
<button class="sb-geofix-cancel" @click="emit('cancel-geofix')">✕ Annuler</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="selectedTechId" class="sb-map-tech"
|
||||||
|
:style="'color:'+TECH_COLORS[store.technicians.find(t=>t.id===selectedTechId)?.colorIdx||0]">
|
||||||
|
● {{ store.technicians.find(t=>t.id===selectedTechId)?.fullName }}
|
||||||
|
<span class="sb-map-route-hint">· Glisser une job sur le trajet</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="sb-map-hint">Cliquer un technicien pour voir son trajet</span>
|
||||||
|
<button class="sb-map-close" @click="emit('close')">✕</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="sb-map-legend">
|
||||||
|
<div v-for="(col, lbl) in SVC_COLORS" :key="lbl" class="sb-legend-item">
|
||||||
|
<span class="sb-legend-dot" :style="'background:'+col"></span>{{ lbl }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="mapContainer" class="sb-map"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject, computed } from 'vue'
|
||||||
|
import { localDateStr, startOfWeek, jobSpansDate } from 'src/composables/useHelpers'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
anchorDate: Date,
|
||||||
|
filteredResources: Array,
|
||||||
|
todayStr: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['go-to-day', 'select-tech'])
|
||||||
|
|
||||||
|
const TECH_COLORS = inject('TECH_COLORS')
|
||||||
|
|
||||||
|
function isDayToday (d) { return localDateStr(d) === props.todayStr }
|
||||||
|
|
||||||
|
const monthWeeks = computed(() => {
|
||||||
|
const first = new Date(props.anchorDate.getFullYear(), props.anchorDate.getMonth(), 1)
|
||||||
|
const last = new Date(props.anchorDate.getFullYear(), props.anchorDate.getMonth() + 1, 0)
|
||||||
|
const start = startOfWeek(first)
|
||||||
|
const end = new Date(last)
|
||||||
|
const dow = end.getDay()
|
||||||
|
if (dow !== 0) end.setDate(end.getDate() + (7 - dow))
|
||||||
|
const weeks = []; let cur = new Date(start)
|
||||||
|
while (cur <= end) {
|
||||||
|
const week = []
|
||||||
|
for (let i = 0; i < 7; i++) { week.push(new Date(cur)); cur.setDate(cur.getDate() + 1) }
|
||||||
|
weeks.push(week)
|
||||||
|
}
|
||||||
|
return weeks
|
||||||
|
})
|
||||||
|
|
||||||
|
function techsActiveOnDay (dateStr) {
|
||||||
|
return props.filteredResources.filter(tech =>
|
||||||
|
tech.queue.some(j => jobSpansDate(j, dateStr)) ||
|
||||||
|
(tech.assistJobs || []).some(j => jobSpansDate(j, dateStr) && j.assistants.find(a => a.techId === tech.id)?.pinned)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayJobCount (dateStr) {
|
||||||
|
const jobIds = new Set()
|
||||||
|
props.filteredResources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr)).forEach(j => jobIds.add(j.id)))
|
||||||
|
return jobIds.size
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sb-month-wrap">
|
||||||
|
<div class="sb-month-dow-hdr">
|
||||||
|
<div v-for="wd in ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim']" :key="wd" class="sb-month-dow">{{ wd }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(week, wi) in monthWeeks" :key="wi" class="sb-month-week">
|
||||||
|
<div v-for="day in week" :key="localDateStr(day)"
|
||||||
|
class="sb-month-day"
|
||||||
|
:class="{ 'sb-month-day-out': day.getMonth() !== anchorDate.getMonth(), 'sb-month-day-today': isDayToday(day) }"
|
||||||
|
@click="emit('go-to-day', day)">
|
||||||
|
<div class="sb-month-day-num">{{ day.getDate() }}</div>
|
||||||
|
<div class="sb-month-avatars">
|
||||||
|
<div v-for="tech in techsActiveOnDay(localDateStr(day))" :key="tech.id"
|
||||||
|
class="sb-month-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]"
|
||||||
|
:title="tech.fullName + ' — ' + tech.queue.filter(j=>jobSpansDate(j,localDateStr(day))).length + ' job(s)'"
|
||||||
|
@click.stop="emit('select-tech', tech)">
|
||||||
|
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="dayJobCount(localDateStr(day))" class="sb-month-job-count">
|
||||||
|
{{ dayJobCount(localDateStr(day)) }} job{{ dayJobCount(localDateStr(day))>1?'s':'' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
104
apps/dispatch/src/modules/dispatch/components/RightPanel.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { fmtDur, prioLabel, prioClass, ICON } from 'src/composables/useHelpers'
|
||||||
|
import TagInput from 'src/components/TagInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
panel: Object, // { mode, data: { job, tech } } or null
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'close', 'edit', 'move', 'geofix', 'unassign',
|
||||||
|
'set-end-date', 'remove-assistant', 'assign-pending',
|
||||||
|
'update-tags',
|
||||||
|
])
|
||||||
|
|
||||||
|
const store = inject('store')
|
||||||
|
const TECH_COLORS = inject('TECH_COLORS')
|
||||||
|
const jobColor = inject('jobColor')
|
||||||
|
const getTagColor = inject('getTagColor')
|
||||||
|
const onCreateTag = inject('onCreateTag')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<transition name="sb-slide-right">
|
||||||
|
<aside v-if="panel" class="sb-right">
|
||||||
|
<div class="sb-rp-hdr">
|
||||||
|
<span class="sb-rp-title">{{ {details:'Détails',pending:'Demande entrante'}[panel.mode] || 'Détails' }}</span>
|
||||||
|
<button class="sb-rp-close" @click="emit('close')">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JOB DETAILS -->
|
||||||
|
<template v-if="panel.mode==='details'">
|
||||||
|
<div class="sb-rp-body">
|
||||||
|
<div class="sb-rp-color-bar" :style="'background:'+jobColor(panel.data?.job||{})"></div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Titre</span><strong>{{ panel.data?.job?.subject }}</strong></div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.job?.address || '—' }}</div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Durée</span>{{ fmtDur(panel.data?.job?.duration) }}</div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Priorité</span>
|
||||||
|
<span :class="prioClass(panel.data?.job?.priority)">{{ prioLabel(panel.data?.job?.priority) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Technicien</span>{{ panel.data?.tech?.fullName || '—' }}</div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Date planifiée</span>
|
||||||
|
{{ panel.data?.job?.scheduledDate || '—' }}
|
||||||
|
<span v-if="panel.data?.job?.endDate"> → {{ panel.data.job.endDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="panel.data?.job?.assignedTech" class="sb-rp-field">
|
||||||
|
<span class="sb-rp-lbl">Date de fin</span>
|
||||||
|
<input type="date" class="sb-form-input" :value="panel.data?.job?.endDate || ''"
|
||||||
|
@change="emit('set-end-date', panel.data.job, $event.target.value)" style="margin-top:2px" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Statut</span>{{ panel.data?.job?.status }}</div>
|
||||||
|
<div v-if="panel.data?.job?.note" class="sb-rp-field"><span class="sb-rp-lbl">Note</span>{{ panel.data.job.note }}</div>
|
||||||
|
<div class="sb-rp-field">
|
||||||
|
<span class="sb-rp-lbl">Tags</span>
|
||||||
|
<TagInput v-if="panel.data?.job"
|
||||||
|
:model-value="panel.data.job.tags || []"
|
||||||
|
@update:model-value="v => emit('update-tags', panel.data.job, v)"
|
||||||
|
:all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
|
||||||
|
</div>
|
||||||
|
<div v-if="panel.data?.job?.assistants?.length" class="sb-rp-field">
|
||||||
|
<span class="sb-rp-lbl">Assistants</span>
|
||||||
|
<div v-for="a in panel.data.job.assistants" :key="a.techId" style="display:flex;align-items:center;gap:6px;margin-top:3px">
|
||||||
|
<span class="sb-assist-badge" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===a.techId)?.colorIdx||0]">
|
||||||
|
{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:0.72rem">{{ a.techName }} · {{ fmtDur(a.duration) }}{{ a.note ? ' · '+a.note : '' }}</span>
|
||||||
|
<button style="margin-left:auto;background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem"
|
||||||
|
@click="emit('remove-assistant', panel.data.job.id, a.techId)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-rp-actions">
|
||||||
|
<button class="sb-rp-primary" @click="emit('edit', panel.data.job)">✏ Modifier</button>
|
||||||
|
<button class="sb-rp-btn" @click="emit('move', panel.data.job, panel.data.tech?.id)">↔ Déplacer / Réassigner</button>
|
||||||
|
<button class="sb-rp-btn" @click="emit('geofix', panel.data.job)">📍 Géofixer sur la carte</button>
|
||||||
|
<button v-if="panel.data?.job?.assignedTech" class="sb-rp-btn sb-ctx-warn" @click="emit('unassign', panel.data.job)">✕ Désaffecter</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- PENDING REQUEST -->
|
||||||
|
<template v-if="panel.mode==='pending'">
|
||||||
|
<div class="sb-rp-body">
|
||||||
|
<div class="sb-rp-color-bar" :style="'background:'+(panel.data?.urgency==='urgent'?'#ef4444':'#f59e0b')"></div>
|
||||||
|
<div v-if="panel.data?.urgency==='urgent'" class="sb-rp-urgent-tag">🚨 Urgent</div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Client</span><strong>{{ panel.data?.customer_name }}</strong></div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Téléphone</span>{{ panel.data?.phone || '—' }}</div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Service</span>{{ panel.data?.service_type }}</div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Problème</span>{{ panel.data?.problem_type || '—' }}</div>
|
||||||
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.address }}</div>
|
||||||
|
<div v-if="panel.data?.budget_label" class="sb-rp-field"><span class="sb-rp-lbl">Budget</span>{{ panel.data?.budget_label }}</div>
|
||||||
|
<div class="sbf-title" style="margin-top:0.75rem">Assigner à</div>
|
||||||
|
<div class="sb-assign-grid">
|
||||||
|
<button v-for="tech in store.technicians" :key="tech.id"
|
||||||
|
class="sb-assign-btn" :style="'border-color:'+TECH_COLORS[tech.colorIdx]"
|
||||||
|
@click="emit('assign-pending', tech.id)">
|
||||||
|
<span class="sb-assign-dot" :style="'background:'+TECH_COLORS[tech.colorIdx]"></span>
|
||||||
|
{{ tech.fullName }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
128
apps/dispatch/src/modules/dispatch/components/TimelineRow.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { ICON, fmtDur, shortAddr, jobStatusIcon, dayLoadColor, stOf } from 'src/composables/useHelpers'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tech: Object,
|
||||||
|
segments: Array, // from techDayJobsWithTravel
|
||||||
|
hourTicks: Array,
|
||||||
|
totalW: Number,
|
||||||
|
pxPerHr: Number,
|
||||||
|
hStart: Number,
|
||||||
|
hEnd: Number,
|
||||||
|
rowH: Number,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isElevated: Boolean,
|
||||||
|
dropGhostX: { type: Number, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'select-tech', 'ctx-tech', 'drag-tech-start', 'reorder-drop',
|
||||||
|
'timeline-dragover', 'timeline-dragleave', 'timeline-drop',
|
||||||
|
'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx',
|
||||||
|
'assist-ctx', 'hover-job', 'unhover-job',
|
||||||
|
'block-move', 'block-resize',
|
||||||
|
])
|
||||||
|
|
||||||
|
const TECH_COLORS = inject('TECH_COLORS')
|
||||||
|
const jobColor = inject('jobColor')
|
||||||
|
const selectedJob = inject('selectedJob')
|
||||||
|
const hoveredJobId = inject('hoveredJobId')
|
||||||
|
const periodLoadH = inject('periodLoadH')
|
||||||
|
const getTagColor = inject('getTagColor')
|
||||||
|
const isJobMultiSelected = inject('isJobMultiSelected')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sb-row" :class="{ 'sb-row-sel': isSelected, 'sb-row-elevated': isElevated }"
|
||||||
|
:style="'height:'+rowH+'px'" :data-tech-id="tech.id">
|
||||||
|
|
||||||
|
<!-- Resource cell -->
|
||||||
|
<div class="sb-res-cell" @click="emit('select-tech', tech)" @contextmenu.prevent="emit('ctx-tech', $event, tech)"
|
||||||
|
draggable="true" @dragstart="emit('drag-tech-start', $event, tech)"
|
||||||
|
@dragover.prevent="()=>{}" @drop.prevent="emit('reorder-drop', $event, tech)">
|
||||||
|
<div class="sb-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]">
|
||||||
|
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
||||||
|
</div>
|
||||||
|
<div class="sb-res-info">
|
||||||
|
<div class="sb-res-name">{{ tech.fullName }}
|
||||||
|
<span v-for="t in (tech.tags||[]).slice(0,3)" :key="t" class="sb-res-tag-dot" :style="'background:'+getTagColor(t)" :title="t"></span>
|
||||||
|
</div>
|
||||||
|
<div class="sb-res-sub">
|
||||||
|
<span class="sb-st" :class="stOf(tech).cls">{{ stOf(tech).label }}</span>
|
||||||
|
<span class="sb-load">{{ fmtDur(periodLoadH(tech)) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sb-util-bar">
|
||||||
|
<div class="sb-util-fill" :style="{ width: Math.min(100,periodLoadH(tech)/8*100)+'%', background: dayLoadColor(periodLoadH(tech)/8) }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="sb-timeline" :style="'width:'+totalW+'px'"
|
||||||
|
@dragover.prevent="emit('timeline-dragover', $event, tech)"
|
||||||
|
@dragleave="emit('timeline-dragleave', $event)"
|
||||||
|
@drop.prevent="emit('timeline-drop', $event, tech)">
|
||||||
|
<!-- Hour guides -->
|
||||||
|
<div v-for="tick in hourTicks.filter(t=>!t.isDay)" :key="'hg-'+tick.x"
|
||||||
|
class="sb-hour-guide" :style="'left:'+tick.x+'px'"></div>
|
||||||
|
<template v-for="h in (hEnd - hStart)" :key="'qg-'+h">
|
||||||
|
<div v-for="q in [1,2,3]" :key="'q-'+h+'-'+q" class="sb-quarter-guide"
|
||||||
|
:style="'left:'+(((h + q*0.25) * pxPerHr))+'px'"></div>
|
||||||
|
</template>
|
||||||
|
<div class="sb-capacity-line" :style="'left:'+((16 - hStart) * pxPerHr)+'px'" title="8h"></div>
|
||||||
|
<div v-if="dropGhostX!=null" class="sb-drop-line" :style="'left:'+dropGhostX+'px'"></div>
|
||||||
|
|
||||||
|
<template v-for="seg in segments" :key="seg.type+'-'+seg.job.id+(seg.isAssist?'-a':'')+(seg.type==='travel'?'-t':'')">
|
||||||
|
<!-- Travel -->
|
||||||
|
<div v-if="seg.type==='travel'" class="sb-travel-trail"
|
||||||
|
:class="[seg.fromRoute?'sb-travel-route':'sb-travel-est', seg.isAssist?'sb-travel-assist':'']"
|
||||||
|
:style="{ ...seg.style, background:seg.color+(seg.fromRoute?'40':'22'), borderLeft:'2px solid '+seg.color+(seg.fromRoute?'88':'44') }">
|
||||||
|
<span v-if="parseFloat(seg.style.width)>36" class="sb-travel-lbl">{{ seg.fromRoute?'':'~' }}{{ seg.travelMin }}min</span>
|
||||||
|
</div>
|
||||||
|
<!-- Assist block -->
|
||||||
|
<div v-else-if="seg.type==='assist'" class="sb-block sb-block-assist"
|
||||||
|
:class="{ 'sb-block-assist-pinned':seg.assistPinned, 'sb-block-sel':selectedJob?.isAssist&&selectedJob?.job?.id===seg.job.id&&selectedJob?.assistTechId===seg.assistTechId, 'sb-block-linked':(selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist)||hoveredJobId===seg.job.id }"
|
||||||
|
:style="{ ...seg.style, background:((selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist)||hoveredJobId===seg.job.id)?jobColor(seg.job)+'dd':(seg.assistPinned?jobColor(seg.job)+'99':jobColor(seg.job)+'44') }"
|
||||||
|
:draggable="seg.assistPinned?'true':'false'"
|
||||||
|
@dragstart="seg.assistPinned && emit('job-dragstart',$event,seg.job,tech.id,true)"
|
||||||
|
@mouseenter="emit('hover-job',seg.job.id)" @mouseleave="emit('unhover-job')"
|
||||||
|
@click.stop="emit('job-click',seg.job,seg.job.assignedTech,true,seg.assistTechId,$event)"
|
||||||
|
@dblclick.stop="emit('job-dblclick',seg.job)"
|
||||||
|
@contextmenu.prevent="emit('assist-ctx',$event,seg.job,seg.assistTechId)">
|
||||||
|
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)"></div>
|
||||||
|
<div class="sb-block-inner">
|
||||||
|
<div class="sb-block-title"><span v-if="seg.assistPinned" class="sb-block-pin" title="Priorisé" v-html="ICON.pin"></span>{{ seg.assistNote||seg.job.subject }}</div>
|
||||||
|
<div class="sb-block-meta">{{ fmtDur(seg.assistDur) }} · {{ seg.job.subject }}{{ seg.job.address?' · '+shortAddr(seg.job.address):'' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'assist',seg.assistTechId)"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Job block -->
|
||||||
|
<div v-else class="sb-block"
|
||||||
|
:class="{ 'sb-block-done':seg.job.status==='completed', 'sb-block-sel':selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist, 'sb-block-multi':isJobMultiSelected(seg.job.id), 'sb-block-linked':selectedJob?.job?.id===seg.job.id&&selectedJob?.isAssist, 'sb-block-team':seg.job.assistants?.length }"
|
||||||
|
:style="{ ...seg.style, background:jobColor(seg.job)+'dd' }"
|
||||||
|
:data-job-id="seg.job.id" draggable="true"
|
||||||
|
@dragstart="emit('job-dragstart',$event,seg.job,tech.id,false)"
|
||||||
|
@mouseenter="emit('hover-job',seg.job.id)" @mouseleave="emit('unhover-job')"
|
||||||
|
@click.stop="emit('job-click',seg.job,tech.id,false,null,$event)"
|
||||||
|
@dblclick.stop="emit('job-dblclick',seg.job)"
|
||||||
|
@contextmenu.prevent="emit('job-ctx',$event,seg.job,tech.id)">
|
||||||
|
<div class="sb-move-handle" @mousedown.stop.prevent="emit('block-move',$event,seg.job,$event.target.parentElement)" title="Déplacer"></div>
|
||||||
|
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)"></div>
|
||||||
|
<div class="sb-block-inner">
|
||||||
|
<div class="sb-block-title"><span v-if="seg.pinned" class="sb-block-pin" title="Heure fixée" v-html="ICON.pin"></span>{{ seg.job.subject }}</div>
|
||||||
|
<div class="sb-block-meta">{{ seg.pinnedTime||'' }}{{ seg.pinnedTime?' · ':'' }}{{ fmtDur(seg.job.duration) }}</div>
|
||||||
|
<div v-if="seg.job.address" class="sb-block-addr"><span v-html="ICON.mapPin"></span> {{ shortAddr(seg.job.address) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="seg.job.assistants?.length" class="sb-block-assistants">
|
||||||
|
<span v-for="a in seg.job.assistants" :key="a.techId" class="sb-assist-badge"
|
||||||
|
:style="'background:'+TECH_COLORS[$root?.$store?.technicians?.find(t=>t.id===a.techId)?.colorIdx||0]"
|
||||||
|
:title="a.techName">{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="jobStatusIcon(seg.job).svg" class="sb-block-status-icon" :class="jobStatusIcon(seg.job).cls" :title="seg.job.status" v-html="jobStatusIcon(seg.job).svg"></span>
|
||||||
|
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'job')"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
112
apps/dispatch/src/modules/dispatch/components/WeekCalendar.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import {
|
||||||
|
localDateStr, fmtDur, shortAddr, dayLoadColor, stOf,
|
||||||
|
ICON, jobSpansDate,
|
||||||
|
} from 'src/composables/useHelpers'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
filteredResources: Array,
|
||||||
|
dayColumns: Array,
|
||||||
|
selectedTechId: String,
|
||||||
|
dropGhost: Object,
|
||||||
|
todayStr: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'go-to-day', 'select-tech', 'ctx-tech',
|
||||||
|
'tech-reorder-start', 'tech-reorder-drop',
|
||||||
|
'cal-drop', 'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx',
|
||||||
|
'clear-filters',
|
||||||
|
])
|
||||||
|
|
||||||
|
const store = inject('store')
|
||||||
|
const TECH_COLORS = inject('TECH_COLORS')
|
||||||
|
const jobColor = inject('jobColor')
|
||||||
|
const selectedJob = inject('selectedJob')
|
||||||
|
const isJobMultiSelected = inject('isJobMultiSelected')
|
||||||
|
const getTagColor = inject('getTagColor')
|
||||||
|
|
||||||
|
function isDayToday (d) { return localDateStr(d) === props.todayStr }
|
||||||
|
|
||||||
|
defineExpose({ isDayToday })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sb-grid sb-grid-cal">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="sb-grid-hdr">
|
||||||
|
<div class="sb-res-hdr">Ressources <span class="sbf-count">{{ filteredResources.length }}</span></div>
|
||||||
|
<div class="sb-cal-hdr">
|
||||||
|
<div v-for="d in dayColumns" :key="'ch-'+localDateStr(d)"
|
||||||
|
class="sb-cal-hdr-cell" :class="{ 'sb-col-today': isDayToday(d) }"
|
||||||
|
style="cursor:pointer" @click="emit('go-to-day', d)">
|
||||||
|
<span class="sb-cal-wd">{{ d.toLocaleDateString('fr-CA',{weekday:'short'}) }}</span>
|
||||||
|
<span class="sb-cal-dn" :class="{ 'sb-today-bubble': isDayToday(d) }">{{ d.getDate() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading / empty -->
|
||||||
|
<div v-if="store.loading" class="sb-loading-row">Chargement…</div>
|
||||||
|
<div v-else-if="!filteredResources.length" class="sb-empty-row">
|
||||||
|
Aucune ressource.
|
||||||
|
<button class="sbf-primary-btn" style="display:inline-block;margin-left:0.75rem" @click="emit('clear-filters')">Réinitialiser</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rows -->
|
||||||
|
<div v-for="tech in filteredResources" :key="tech.id"
|
||||||
|
class="sb-row sb-row-cal" :class="{ 'sb-row-sel': selectedTechId===tech.id }">
|
||||||
|
<div class="sb-res-cell" @click="emit('select-tech', tech)" @contextmenu.prevent="emit('ctx-tech', $event, tech)"
|
||||||
|
draggable="true" @dragstart="emit('tech-reorder-start', $event, tech)"
|
||||||
|
@dragover.prevent="()=>{}" @drop.prevent="emit('tech-reorder-drop', $event, tech)">
|
||||||
|
<div class="sb-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]">
|
||||||
|
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
||||||
|
</div>
|
||||||
|
<div class="sb-res-info">
|
||||||
|
<div class="sb-res-name">{{ tech.fullName }}
|
||||||
|
<span v-for="t in (tech.tags||[]).slice(0,3)" :key="t" class="sb-res-tag-dot" :style="'background:'+getTagColor(t)" :title="t"></span>
|
||||||
|
</div>
|
||||||
|
<div class="sb-res-sub">
|
||||||
|
<span class="sb-st" :class="stOf(tech).cls">{{ stOf(tech).label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-cal-row">
|
||||||
|
<div v-for="d in dayColumns" :key="localDateStr(d)"
|
||||||
|
class="sb-cal-cell" :class="{ 'sb-bg-today': isDayToday(d), 'sb-bg-alt': dayColumns.indexOf(d)%2===1 }"
|
||||||
|
:data-date-str="localDateStr(d)"
|
||||||
|
@dblclick="emit('go-to-day', d)"
|
||||||
|
@dragover.prevent="()=>{}" @dragleave="()=>{}"
|
||||||
|
@drop.prevent="emit('cal-drop', $event, tech, localDateStr(d))">
|
||||||
|
<div v-if="dropGhost?.techId===tech.id && dropGhost.dateStr===localDateStr(d)" class="sb-cal-drop"></div>
|
||||||
|
<template v-for="job in [...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))), ...(tech.assistJobs||[]).filter(j=>jobSpansDate(j,localDateStr(d))&&j.assistants.find(a=>a.techId===tech.id)?.pinned).map(j=>({...j,_isAssistChip:true,_assistDur:j.assistants.find(a=>a.techId===tech.id)?.duration||j.duration}))]" :key="job.id+(job._isAssistChip?'-a':'')">
|
||||||
|
<div class="sb-chip"
|
||||||
|
:class="{ 'sb-chip-sel': selectedJob?.job?.id===job.id, 'sb-chip-multi': isJobMultiSelected(job.id), 'sb-chip-assist': job._isAssistChip }"
|
||||||
|
:data-job-id="job.id"
|
||||||
|
:style="'border-left-color:'+jobColor(job)+';background:'+jobColor(job)+'cc;color:#fff'"
|
||||||
|
:draggable="job._isAssistChip ? 'false' : 'true'"
|
||||||
|
@dragstart="!job._isAssistChip && emit('job-dragstart', $event, job, tech.id)"
|
||||||
|
@click.stop="emit('job-click', job, tech.id, false, null, $event)"
|
||||||
|
@dblclick.stop="emit('job-dblclick', job)"
|
||||||
|
@contextmenu.prevent="emit('job-ctx', $event, job, tech.id)">
|
||||||
|
<div class="sb-chip-line1">
|
||||||
|
<span v-if="job.priority==='high'" class="sb-chip-urgent"></span>
|
||||||
|
<span v-if="job._isAssistChip" class="sb-chip-assist-tag" v-html="ICON.pin"></span>
|
||||||
|
{{ job.subject }}
|
||||||
|
</div>
|
||||||
|
<div v-if="job.address" class="sb-chip-line2"><span v-html="ICON.mapPin"></span> {{ shortAddr(job.address) }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- Day load bar -->
|
||||||
|
<div v-if="[...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d)))].length" class="sb-day-load">
|
||||||
|
<div class="sb-day-load-track">
|
||||||
|
<div class="sb-day-load-fill" :style="{ width: Math.min(100, tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/8*100)+'%', background: dayLoadColor(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/8) }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="sb-day-load-label">{{ fmtDur(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)) }}/8h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import TagInput from 'src/components/TagInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ modelValue: Object })
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
|
||||||
|
|
||||||
|
const store = inject('store')
|
||||||
|
const MAPBOX_TOKEN = inject('MAPBOX_TOKEN')
|
||||||
|
const getTagColor = inject('getTagColor')
|
||||||
|
const onCreateTag = inject('onCreateTag')
|
||||||
|
const searchAddr = inject('searchAddr')
|
||||||
|
const addrResults = inject('addrResults')
|
||||||
|
const selectAddr = inject('selectAddr')
|
||||||
|
|
||||||
|
function close () { emit('update:modelValue', null); emit('cancel') }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="modelValue" class="sb-overlay" @click.self="close">
|
||||||
|
<div class="sb-modal sb-modal-wo">
|
||||||
|
<div class="sb-modal-hdr">
|
||||||
|
<span>+ Nouveau work order</span>
|
||||||
|
<button class="sb-rp-close" @click="close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-body sb-wo-body">
|
||||||
|
<div class="sb-wo-form">
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Titre *</label>
|
||||||
|
<input class="sb-form-input" v-model="modelValue.subject" placeholder="Ex: Remplacement modem" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Adresse</label>
|
||||||
|
<div class="sb-addr-wrap">
|
||||||
|
<input class="sb-form-input" v-model="modelValue.address" placeholder="123 rue Exemple, Montréal"
|
||||||
|
@input="searchAddr(modelValue.address)" @blur="setTimeout(()=>addrResults.length=0,200)" autocomplete="off" />
|
||||||
|
<div v-if="addrResults.length" class="sb-addr-dropdown">
|
||||||
|
<div v-for="a in addrResults" :key="a.address_full" class="sb-addr-item"
|
||||||
|
@mousedown.prevent="selectAddr(a, modelValue)">
|
||||||
|
<strong>{{ a.address_full }}</strong>
|
||||||
|
<span v-if="a.code_postal" class="sb-addr-cp">{{ a.code_postal }}</span>
|
||||||
|
<span v-if="a.ville" class="sb-addr-city">{{ a.ville }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="modelValue.latitude" class="sb-addr-confirmed">
|
||||||
|
✓ {{ modelValue.ville || '' }} {{ modelValue.code_postal || '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Note</label>
|
||||||
|
<textarea class="sb-form-input" v-model="modelValue.note" rows="2" placeholder="Ex: chien dangereux, sonner 2x…" style="resize:vertical"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Durée (h)</label>
|
||||||
|
<input type="number" class="sb-form-input" v-model.number="modelValue.duration_h" min="0.5" max="12" step="0.5" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Priorité</label>
|
||||||
|
<select class="sb-form-sel" v-model="modelValue.priority">
|
||||||
|
<option value="low">Basse</option>
|
||||||
|
<option value="medium">Moyenne</option>
|
||||||
|
<option value="high">Haute</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Tags / Skills</label>
|
||||||
|
<TagInput v-model="modelValue.tags" :all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row">
|
||||||
|
<label class="sb-form-lbl">Technicien</label>
|
||||||
|
<select class="sb-form-sel" v-model="modelValue.techId">
|
||||||
|
<option value="">— Non assigné —</option>
|
||||||
|
<option v-for="t in store.technicians" :key="t.id" :value="t.id">{{ t.fullName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sb-form-row" v-if="modelValue.techId">
|
||||||
|
<label class="sb-form-lbl">Date planifiée</label>
|
||||||
|
<input type="date" class="sb-form-input" v-model="modelValue.date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="modelValue.latitude" class="sb-wo-minimap">
|
||||||
|
<img :src="'https://api.mapbox.com/styles/v1/mapbox/dark-v11/static/pin-s+6366f1('+modelValue.longitude+','+modelValue.latitude+')/'+modelValue.longitude+','+modelValue.latitude+',14,0/280x280@2x?access_token='+MAPBOX_TOKEN"
|
||||||
|
alt="Carte" class="sb-minimap-img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-ftr">
|
||||||
|
<button class="sbf-primary-btn" :disabled="!modelValue.subject?.trim()" @click="emit('confirm')">✓ Créer</button>
|
||||||
|
<button class="sb-rp-btn" @click="close">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
557
apps/dispatch/src/pages/AdminPage.vue
Normal file
|
|
@ -0,0 +1,557 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { fetchSettings, saveSettings, createDocType } from 'src/api/settings'
|
||||||
|
import { MAPBOX_TOKEN } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// ── Valeurs par défaut (pré-remplissage) ────────────────────────────────────
|
||||||
|
const form = ref({
|
||||||
|
// ERPNext
|
||||||
|
erp_url: window.location.origin,
|
||||||
|
erp_api_key: '',
|
||||||
|
erp_api_secret: '',
|
||||||
|
// Mapbox
|
||||||
|
mapbox_token: MAPBOX_TOKEN,
|
||||||
|
// Twilio
|
||||||
|
twilio_account_sid: '',
|
||||||
|
twilio_auth_token: '',
|
||||||
|
twilio_from_number: '',
|
||||||
|
// Stripe
|
||||||
|
stripe_mode: 'test',
|
||||||
|
stripe_publishable_key: '',
|
||||||
|
stripe_secret_key: '',
|
||||||
|
stripe_webhook_secret:'',
|
||||||
|
// n8n
|
||||||
|
n8n_url: 'http://localhost:5678',
|
||||||
|
n8n_api_key: '',
|
||||||
|
n8n_webhook_base: 'http://localhost:5678/webhook',
|
||||||
|
// Templates SMS
|
||||||
|
sms_enroute: 'Bonjour {client_name}, votre technicien {tech_name} est en route et arrivera dans environ {eta} minutes. Réf: {job_id}',
|
||||||
|
sms_completed: 'Bonjour {client_name}, votre service ({job_id}) a été complété avec succès. Merci de votre confiance !',
|
||||||
|
sms_assigned: 'Nouveau job assigné : {job_id} — {client_name}, {address}. Durée estimée : {duration}h.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── État page ────────────────────────────────────────────────────────────────
|
||||||
|
const loading = ref(true)
|
||||||
|
const docTypeError = ref(false)
|
||||||
|
const initStatus = ref(null) // null | 'creating' | 'done' | 'error'
|
||||||
|
const initError = ref('')
|
||||||
|
const saveStatus = ref(null) // null | 'saving' | 'saved' | 'error'
|
||||||
|
const saveError = ref('')
|
||||||
|
|
||||||
|
// ── Statuts de connexion ─────────────────────────────────────────────────────
|
||||||
|
const st = ref({ erp: null, mapbox: null, twilio: null, stripe: null, n8n: null })
|
||||||
|
// null | 'testing' | 'ok' | 'error' | 'warn'
|
||||||
|
|
||||||
|
// ── Révéler / masquer les mots de passe ──────────────────────────────────────
|
||||||
|
const show = ref({
|
||||||
|
erp_api_secret: false, twilio_auth_token: false,
|
||||||
|
stripe_secret_key: false, stripe_webhook_secret: false, n8n_api_key: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Chargement initial ───────────────────────────────────────────────────────
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchSettings()
|
||||||
|
Object.keys(form.value).forEach(k => {
|
||||||
|
if (data[k] !== undefined && data[k] !== null && data[k] !== '') {
|
||||||
|
form.value[k] = data[k]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === 'DOCTYPE_NOT_FOUND') docTypeError.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Sauvegarde ───────────────────────────────────────────────────────────────
|
||||||
|
async function init () {
|
||||||
|
initStatus.value = 'creating'
|
||||||
|
initError.value = ''
|
||||||
|
try {
|
||||||
|
await createDocType()
|
||||||
|
initStatus.value = 'done'
|
||||||
|
docTypeError.value = false
|
||||||
|
// Reload settings after creation
|
||||||
|
const data = await fetchSettings().catch(() => ({}))
|
||||||
|
Object.keys(form.value).forEach(k => {
|
||||||
|
if (data[k] !== undefined && data[k] !== null && data[k] !== '') form.value[k] = data[k]
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
initStatus.value = 'error'
|
||||||
|
initError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save () {
|
||||||
|
saveStatus.value = 'saving'
|
||||||
|
saveError.value = ''
|
||||||
|
try {
|
||||||
|
await saveSettings(form.value)
|
||||||
|
saveStatus.value = 'saved'
|
||||||
|
setTimeout(() => { saveStatus.value = null }, 2500)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === 'DOCTYPE_NOT_FOUND') { docTypeError.value = true }
|
||||||
|
saveStatus.value = 'error'
|
||||||
|
saveError.value = e.message === 'DOCTYPE_NOT_FOUND'
|
||||||
|
? 'DocType manquant — cliquez sur Initialiser'
|
||||||
|
: e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests de connexion ───────────────────────────────────────────────────────
|
||||||
|
async function testErp () {
|
||||||
|
st.value.erp = 'testing'
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${form.value.erp_url}/api/method/frappe.auth.get_logged_user`, { credentials: 'include' })
|
||||||
|
const d = await r.json()
|
||||||
|
st.value.erp = (d.message && d.message !== 'Guest') ? 'ok' : 'error'
|
||||||
|
} catch { st.value.erp = 'error' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testMapbox () {
|
||||||
|
st.value.mapbox = 'testing'
|
||||||
|
try {
|
||||||
|
const r = await fetch(`https://api.mapbox.com/tokens/v2?access_token=${form.value.mapbox_token}`)
|
||||||
|
st.value.mapbox = r.ok ? 'ok' : 'error'
|
||||||
|
} catch { st.value.mapbox = 'error' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function testTwilio () {
|
||||||
|
const sid = form.value.twilio_account_sid
|
||||||
|
if (!sid) { st.value.twilio = 'warn'; return }
|
||||||
|
st.value.twilio = (sid.startsWith('AC') && sid.length === 34) ? 'ok' : 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function testStripe () {
|
||||||
|
const key = form.value.stripe_secret_key
|
||||||
|
if (!key) { st.value.stripe = 'warn'; return }
|
||||||
|
st.value.stripe = (key.startsWith('sk_test_') || key.startsWith('sk_live_')) ? 'ok' : 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testN8n () {
|
||||||
|
st.value.n8n = 'testing'
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${form.value.n8n_url}/healthz`)
|
||||||
|
st.value.n8n = r.ok ? 'ok' : 'error'
|
||||||
|
} catch { st.value.n8n = 'error' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function stLabel (s) {
|
||||||
|
return { ok: '● Connecté', error: '✗ Erreur', warn: '○ Non configuré', testing: '… Test…' }[s] ?? '○ Non testé'
|
||||||
|
}
|
||||||
|
function stClass (s) {
|
||||||
|
return { ok: 'st-ok', error: 'st-error', warn: 'st-warn', testing: 'st-testing' }[s] ?? 'st-none'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-root">
|
||||||
|
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<div class="admin-header">
|
||||||
|
<div class="admin-header-left">
|
||||||
|
<button class="btn-back" @click="router.push('/')">← Dispatch</button>
|
||||||
|
<div class="admin-title">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||||
|
</svg>
|
||||||
|
Paramètres de la plateforme
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-header-right">
|
||||||
|
<span v-if="saveStatus === 'saved'" class="save-feedback ok">✓ Sauvegardé</span>
|
||||||
|
<span v-if="saveStatus === 'error'" class="save-feedback err">✗ {{ saveError }}</span>
|
||||||
|
<button class="btn-save" :disabled="saveStatus === 'saving' || docTypeError" @click="save">
|
||||||
|
{{ saveStatus === 'saving' ? 'Sauvegarde…' : 'Sauvegarder' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DocType manquant — bouton d'initialisation ── -->
|
||||||
|
<div v-if="docTypeError" class="doctype-error">
|
||||||
|
<strong>⚠ Première utilisation — DocType non initialisé</strong>
|
||||||
|
<p>
|
||||||
|
Le DocType <code>Dispatch Settings</code> n'existe pas encore dans ERPNext.
|
||||||
|
Cliquez sur le bouton ci-dessous pour le créer automatiquement.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;margin-top:0.75rem;flex-wrap:wrap;">
|
||||||
|
<button class="btn-init" :disabled="initStatus === 'creating'" @click="init">
|
||||||
|
{{ initStatus === 'creating' ? '⏳ Création en cours…' : '⚡ Initialiser dans ERPNext' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="initStatus === 'done'" style="color:#10b981;font-weight:700;">✓ DocType créé — paramètres disponibles</span>
|
||||||
|
<span v-if="initStatus === 'error'" style="color:#f43f5e;font-size:0.8rem;">✗ {{ initError }}</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:0.75rem;font-size:0.78rem;color:#64748b;">
|
||||||
|
Requiert le rôle <strong>System Manager</strong> dans ERPNext.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Chargement ── -->
|
||||||
|
<div v-if="loading" class="loading-state">Chargement des paramètres…</div>
|
||||||
|
|
||||||
|
<!-- ── Formulaire ── -->
|
||||||
|
<div v-else-if="!docTypeError" class="admin-body">
|
||||||
|
|
||||||
|
<!-- ── ERPNext ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">🔗</span>
|
||||||
|
<span class="card-title">ERPNext / Frappe</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.erp)">{{ stLabel(st.erp) }}</span>
|
||||||
|
<button class="btn-test" @click="testErp">Tester</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>URL du serveur</label>
|
||||||
|
<input v-model="form.erp_url" type="text" placeholder="http://localhost:8080" />
|
||||||
|
<span class="field-hint">Vide = même origine que l'app</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input v-model="form.erp_api_key" type="text" placeholder="Profil → API Access → API Key" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>API Secret</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.erp_api_secret" :type="show.erp_api_secret ? 'text' : 'password'" placeholder="••••••••••••••" />
|
||||||
|
<button class="btn-reveal" @click="show.erp_api_secret = !show.erp_api_secret">{{ show.erp_api_secret ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Mapbox ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">🗺️</span>
|
||||||
|
<span class="card-title">Mapbox</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.mapbox)">{{ stLabel(st.mapbox) }}</span>
|
||||||
|
<button class="btn-test" @click="testMapbox">Tester</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Token public (pk_…)</label>
|
||||||
|
<input v-model="form.mapbox_token" type="text" placeholder="pk.eyJ1Ij…" />
|
||||||
|
<span class="field-hint">Token public — visible navigateur. Limitez le scope dans le dashboard Mapbox.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Twilio ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">💬</span>
|
||||||
|
<span class="card-title">Twilio — SMS</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.twilio)">{{ stLabel(st.twilio) }}</span>
|
||||||
|
<button class="btn-test" @click="testTwilio">Vérifier</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Account SID</label>
|
||||||
|
<input v-model="form.twilio_account_sid" type="text" placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
|
||||||
|
<span class="field-hint">Commence par AC, 34 caractères — console.twilio.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Auth Token</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.twilio_auth_token" :type="show.twilio_auth_token ? 'text' : 'password'" placeholder="••••••••••••••" />
|
||||||
|
<button class="btn-reveal" @click="show.twilio_auth_token = !show.twilio_auth_token">{{ show.twilio_auth_token ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="max-width:260px">
|
||||||
|
<label>Numéro expéditeur</label>
|
||||||
|
<input v-model="form.twilio_from_number" type="text" placeholder="+15141234567" />
|
||||||
|
<span class="field-hint">Format E.164 — numéro Twilio acheté</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Stripe ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">💳</span>
|
||||||
|
<span class="card-title">Stripe — Paiements</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.stripe)">{{ stLabel(st.stripe) }}</span>
|
||||||
|
<button class="btn-test" @click="testStripe">Vérifier</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field" style="max-width:200px">
|
||||||
|
<label>Mode</label>
|
||||||
|
<select v-model="form.stripe_mode">
|
||||||
|
<option value="test">Test</option>
|
||||||
|
<option value="live">Production (live)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Clé publique (pk_…)</label>
|
||||||
|
<input v-model="form.stripe_publishable_key" type="text" placeholder="pk_test_…" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Clé secrète (sk_…)</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.stripe_secret_key" :type="show.stripe_secret_key ? 'text' : 'password'" placeholder="sk_test_…" />
|
||||||
|
<button class="btn-reveal" @click="show.stripe_secret_key = !show.stripe_secret_key">{{ show.stripe_secret_key ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Webhook Secret (whsec_…)</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.stripe_webhook_secret" :type="show.stripe_webhook_secret ? 'text' : 'password'" placeholder="whsec_…" />
|
||||||
|
<button class="btn-reveal" @click="show.stripe_webhook_secret = !show.stripe_webhook_secret">{{ show.stripe_webhook_secret ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── n8n ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">⚙️</span>
|
||||||
|
<span class="card-title">n8n — Automatisation</span>
|
||||||
|
<span class="st-badge" :class="stClass(st.n8n)">{{ stLabel(st.n8n) }}</span>
|
||||||
|
<button class="btn-test" @click="testN8n">Tester</button>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>URL n8n</label>
|
||||||
|
<input v-model="form.n8n_url" type="text" placeholder="http://localhost:5678" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>API Key n8n</label>
|
||||||
|
<div class="input-pw">
|
||||||
|
<input v-model="form.n8n_api_key" :type="show.n8n_api_key ? 'text' : 'password'" placeholder="••••••••••••••" />
|
||||||
|
<button class="btn-reveal" @click="show.n8n_api_key = !show.n8n_api_key">{{ show.n8n_api_key ? '🙈' : '👁' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Base URL webhooks ERPNext → n8n</label>
|
||||||
|
<input v-model="form.n8n_webhook_base" type="text" placeholder="http://localhost:5678/webhook" />
|
||||||
|
<span class="field-hint">Préfixe utilisé pour configurer les webhooks ERPNext. Ex: {base}/job-enroute</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Templates SMS ── -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon">📝</span>
|
||||||
|
<span class="card-title">Templates SMS</span>
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<span class="field-hint" style="margin-bottom:0.75rem;display:block;">
|
||||||
|
Variables disponibles : <code>{client_name}</code> <code>{tech_name}</code> <code>{job_id}</code> <code>{eta}</code> <code>{address}</code> <code>{duration}</code>
|
||||||
|
</span>
|
||||||
|
<div class="field">
|
||||||
|
<label>Technicien en route</label>
|
||||||
|
<textarea v-model="form.sms_enroute" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Service complété</label>
|
||||||
|
<textarea v-model="form.sms_completed" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Job assigné (notification technicien)</label>
|
||||||
|
<textarea v-model="form.sms_assigned" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bouton bas ── -->
|
||||||
|
<div class="bottom-bar">
|
||||||
|
<span v-if="saveStatus === 'saved'" class="save-feedback ok">✓ Paramètres sauvegardés dans ERPNext</span>
|
||||||
|
<span v-if="saveStatus === 'error'" class="save-feedback err">✗ {{ saveError }}</span>
|
||||||
|
<button class="btn-save large" :disabled="saveStatus === 'saving'" @click="save">
|
||||||
|
{{ saveStatus === 'saving' ? 'Sauvegarde…' : 'Sauvegarder les paramètres' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Thème (reprend les variables CSS de DispatchPage) ── */
|
||||||
|
.admin-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
color: var(--text-primary, #f1f5f9);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.admin-header {
|
||||||
|
position: sticky; top: 0; z-index: 20;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.admin-header-left { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
.admin-header-right { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.admin-title {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
font-size: 1rem; font-weight: 700; color: var(--text-primary, #f1f5f9);
|
||||||
|
}
|
||||||
|
.btn-back {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
border-radius: 6px; padding: 0.3rem 0.75rem;
|
||||||
|
cursor: pointer; font-size: 0.8rem; font-weight: 600;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-back:hover { color: var(--text-primary, #f1f5f9); border-color: var(--accent, #6366f1); }
|
||||||
|
.btn-save {
|
||||||
|
background: var(--accent, #6366f1); border: none; color: white;
|
||||||
|
border-radius: 8px; padding: 0.45rem 1.25rem;
|
||||||
|
cursor: pointer; font-size: 0.82rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-save:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.btn-save:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.btn-save.large { padding: 0.6rem 2rem; font-size: 0.9rem; }
|
||||||
|
.save-feedback { font-size: 0.8rem; font-weight: 600; }
|
||||||
|
.save-feedback.ok { color: #10b981; }
|
||||||
|
.save-feedback.err { color: #f43f5e; }
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.admin-body {
|
||||||
|
max-width: 860px; margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.5rem 4rem;
|
||||||
|
display: flex; flex-direction: column; gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.settings-card {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
.card-icon { font-size: 1.1rem; }
|
||||||
|
.card-title { font-size: 0.9rem; font-weight: 700; flex: 1; }
|
||||||
|
|
||||||
|
/* ── Status badges ── */
|
||||||
|
.st-badge {
|
||||||
|
font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 8px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.st-ok { background: rgba(16,185,129,0.15); color: #10b981; }
|
||||||
|
.st-error { background: rgba(244,63,94,0.15); color: #f43f5e; }
|
||||||
|
.st-warn { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
||||||
|
.st-testing { background: rgba(99,102,241,0.15); color: #6366f1; }
|
||||||
|
.st-none { background: rgba(148,163,184,0.1); color: #64748b; }
|
||||||
|
|
||||||
|
.btn-test {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
border-radius: 6px; padding: 0.2rem 0.6rem;
|
||||||
|
cursor: pointer; font-size: 0.72rem; font-weight: 600;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-test:hover { border-color: var(--accent, #6366f1); color: var(--accent, #6366f1); }
|
||||||
|
|
||||||
|
/* ── Fields ── */
|
||||||
|
.fields { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
|
||||||
|
.field-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.field-row .field { flex: 1; min-width: 180px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.field label {
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em; color: var(--text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
.field input, .field select, .field textarea {
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 6px; color: var(--text-primary, #f1f5f9);
|
||||||
|
padding: 0.45rem 0.75rem; font-size: 0.82rem;
|
||||||
|
font-family: 'Inter', monospace; outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus {
|
||||||
|
border-color: var(--accent, #6366f1);
|
||||||
|
}
|
||||||
|
.field textarea { resize: vertical; line-height: 1.5; }
|
||||||
|
.field select { cursor: pointer; }
|
||||||
|
.field-hint { font-size: 0.7rem; color: var(--text-secondary, #64748b); line-height: 1.4; }
|
||||||
|
.field code {
|
||||||
|
font-family: monospace; font-size: 0.7rem;
|
||||||
|
background: rgba(99,102,241,0.12); color: #a5b4fc;
|
||||||
|
padding: 0.1rem 0.3rem; border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Password reveal ── */
|
||||||
|
.input-pw { display: flex; gap: 0; }
|
||||||
|
.input-pw input {
|
||||||
|
flex: 1; border-radius: 6px 0 0 6px;
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
.btn-reveal {
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-left: none; border-radius: 0 6px 6px 0;
|
||||||
|
padding: 0 0.55rem; cursor: pointer; font-size: 0.9rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-reveal:hover { background: var(--card-bg, rgba(255,255,255,0.04)); }
|
||||||
|
|
||||||
|
.btn-init {
|
||||||
|
background: var(--accent, #6366f1); border: none; color: white;
|
||||||
|
border-radius: 8px; padding: 0.55rem 1.25rem;
|
||||||
|
cursor: pointer; font-size: 0.85rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-init:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.btn-init:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Erreur DocType ── */
|
||||||
|
.doctype-error {
|
||||||
|
max-width: 780px; margin: 2rem auto;
|
||||||
|
background: rgba(244,63,94,0.08);
|
||||||
|
border: 1px solid rgba(244,63,94,0.25);
|
||||||
|
border-radius: 12px; padding: 1.5rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.doctype-error strong { color: #f43f5e; display: block; margin-bottom: 0.5rem; }
|
||||||
|
.doctype-error pre {
|
||||||
|
background: rgba(0,0,0,0.4); border-radius: 8px; padding: 0.9rem 1rem;
|
||||||
|
font-size: 0.75rem; white-space: pre-wrap; overflow-x: auto;
|
||||||
|
color: #a5b4fc; margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.doctype-error code {
|
||||||
|
font-family: monospace; font-size: 0.82rem; color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chargement ── */
|
||||||
|
.loading-state {
|
||||||
|
text-align: center; padding: 4rem;
|
||||||
|
color: var(--text-secondary, #94a3b8); font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bottom bar ── */
|
||||||
|
.bottom-bar {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end;
|
||||||
|
gap: 1rem; padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
583
apps/dispatch/src/pages/BookingPage.vue
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { MAPBOX_TOKEN } from 'src/config/erpnext'
|
||||||
|
import { createServiceRequest } from 'src/api/service-request'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// ── Services télécom ──────────────────────────────────────────────────────────
|
||||||
|
const SERVICES = [
|
||||||
|
{ id: 'internet', icon: '🌐', label: 'Internet', desc: 'Connexion lente, coupures, Wi-Fi' },
|
||||||
|
{ id: 'tv', icon: '📺', label: 'Télévision', desc: 'Câble, satellite, IPTV, décodeur' },
|
||||||
|
{ id: 'telephone', icon: '📞', label: 'Téléphonie', desc: 'Résidentiel, VoIP, interphones' },
|
||||||
|
{ id: 'multi', icon: '🔧', label: 'Services multiples', desc: 'Problème combiné' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PROBLEMS = {
|
||||||
|
internet: [
|
||||||
|
'Pas de connexion internet', 'Connexion intermittente', 'Vitesse très lente',
|
||||||
|
'Signal Wi-Fi faible', 'Modem / routeur défaillant', 'Installation câblage réseau',
|
||||||
|
'Configuration réseau (IP, DNS)', 'Autre',
|
||||||
|
],
|
||||||
|
tv: [
|
||||||
|
"Pas de signal TV", 'Image pixelisée / gelée', 'Canaux manquants',
|
||||||
|
'Décodeur défaillant', 'Installation antenne / câble', 'Configuration IPTV',
|
||||||
|
'Télécommande défectueuse', 'Autre',
|
||||||
|
],
|
||||||
|
telephone: [
|
||||||
|
"Pas de tonalité", 'Mauvaise qualité audio', 'Ligne coupée',
|
||||||
|
'Installation VoIP', 'Portabilité de numéro', 'Installation câblage téléphonique',
|
||||||
|
'Configuration central téléphonique', 'Autre',
|
||||||
|
],
|
||||||
|
multi: ['Décrire le problème dans la zone de texte ci-dessous'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_SLOTS = [
|
||||||
|
{ id: 'morning', label: 'Matin', sub: '8h–12h', icon: '🌅' },
|
||||||
|
{ id: 'afternoon', label: 'Après-midi', sub: '12h–17h', icon: '☀️' },
|
||||||
|
{ id: 'evening', label: 'Soir', sub: '17h–20h', icon: '🌙' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BUDGET_OPTIONS = [
|
||||||
|
{ id: 'b50', label: '50–100 $', min: 50, max: 100 },
|
||||||
|
{ id: 'b100', label: '100–200 $', min: 100, max: 200 },
|
||||||
|
{ id: 'b200', label: '200–350 $', min: 200, max: 350 },
|
||||||
|
{ id: 'b350', label: '350 $+', min: 350, max: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TOTAL_STEPS = 5
|
||||||
|
const step = ref(1)
|
||||||
|
|
||||||
|
// ── Étape 1 : type de service ─────────────────────────────────────────────────
|
||||||
|
const selectedService = ref(null)
|
||||||
|
|
||||||
|
// ── Étape 2 : description du problème ────────────────────────────────────────
|
||||||
|
const selectedProblem = ref(null)
|
||||||
|
const description = ref('')
|
||||||
|
|
||||||
|
// ── Étape 3 : adresse ─────────────────────────────────────────────────────────
|
||||||
|
const address = ref(null)
|
||||||
|
const addressQuery = ref('')
|
||||||
|
const addressSuggestions = ref([])
|
||||||
|
const addressLoading = ref(false)
|
||||||
|
let debounceTimer = null
|
||||||
|
|
||||||
|
function onAddressInput (e) {
|
||||||
|
addressQuery.value = e.target.value
|
||||||
|
address.value = null
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
if (addressQuery.value.length < 3) { addressSuggestions.value = []; return }
|
||||||
|
debounceTimer = setTimeout(fetchSuggestions, 350)
|
||||||
|
}
|
||||||
|
async function fetchSuggestions () {
|
||||||
|
addressLoading.value = true
|
||||||
|
try {
|
||||||
|
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(addressQuery.value)}.json`
|
||||||
|
+ `?access_token=${MAPBOX_TOKEN}&country=CA&language=fr&limit=5`
|
||||||
|
const r = await fetch(url)
|
||||||
|
const d = await r.json()
|
||||||
|
addressSuggestions.value = d.features || []
|
||||||
|
} catch (_) { addressSuggestions.value = [] }
|
||||||
|
addressLoading.value = false
|
||||||
|
}
|
||||||
|
function selectAddress (f) {
|
||||||
|
address.value = f
|
||||||
|
addressQuery.value = f.place_name
|
||||||
|
addressSuggestions.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Étape 4 : 3 dates préférées ───────────────────────────────────────────────
|
||||||
|
const minDate = computed(() => new Date().toISOString().split('T')[0])
|
||||||
|
const preferredDates = ref([
|
||||||
|
{ date: '', timeSlots: [] }, // timeSlots = array of slot IDs (multi-select)
|
||||||
|
{ date: '', timeSlots: [] },
|
||||||
|
{ date: '', timeSlots: [] },
|
||||||
|
])
|
||||||
|
const urgency = ref('normal')
|
||||||
|
const budgetId = ref(null) // selected BUDGET_OPTIONS id
|
||||||
|
|
||||||
|
const activeDateIdx = ref(0) // which date card is open
|
||||||
|
|
||||||
|
function dateLabel (iso) {
|
||||||
|
if (!iso) return null
|
||||||
|
const d = new Date(iso + 'T12:00:00')
|
||||||
|
return d.toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSlot (pd, slotId) {
|
||||||
|
if (pd.timeSlots.includes(slotId)) {
|
||||||
|
pd.timeSlots = pd.timeSlots.filter(s => s !== slotId)
|
||||||
|
} else {
|
||||||
|
pd.timeSlots = [...pd.timeSlots, slotId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDates = computed(() => preferredDates.value.filter(d => d.date && d.timeSlots.length > 0))
|
||||||
|
|
||||||
|
// ── Étape 5 : contact ─────────────────────────────────────────────────────────
|
||||||
|
const contact = ref({ name: '', phone: '', email: '' })
|
||||||
|
|
||||||
|
// ── Validation ────────────────────────────────────────────────────────────────
|
||||||
|
const canNext = computed(() => {
|
||||||
|
if (step.value === 1) return !!selectedService.value
|
||||||
|
if (step.value === 2) return !!selectedProblem.value
|
||||||
|
if (step.value === 3) return !!address.value
|
||||||
|
if (step.value === 4) return validDates.value.length >= 1 && !!budgetId.value
|
||||||
|
if (step.value === 5) return contact.value.name.trim() && contact.value.phone.trim()
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
function next () { if (canNext.value && step.value < TOTAL_STEPS) step.value++ }
|
||||||
|
function prev () { if (step.value > 1) step.value-- }
|
||||||
|
|
||||||
|
// ── Soumission ────────────────────────────────────────────────────────────────
|
||||||
|
const submitting = ref(false)
|
||||||
|
const confirmed = ref(false)
|
||||||
|
const refNumber = ref('')
|
||||||
|
|
||||||
|
async function submit () {
|
||||||
|
if (!canNext.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const result = await createServiceRequest({
|
||||||
|
service_type: selectedService.value,
|
||||||
|
problem_type: selectedProblem.value,
|
||||||
|
description: description.value,
|
||||||
|
address: address.value?.place_name || addressQuery.value,
|
||||||
|
coordinates: address.value?.center || [0, 0],
|
||||||
|
preferred_dates: validDates.value.map(d => ({
|
||||||
|
date: d.date,
|
||||||
|
time_slots: d.timeSlots,
|
||||||
|
time_slot: d.timeSlots[0] || '', // backward-compat primary slot
|
||||||
|
})),
|
||||||
|
urgency: urgency.value,
|
||||||
|
budget: BUDGET_OPTIONS.find(b => b.id === budgetId.value) || null,
|
||||||
|
contact: contact.value,
|
||||||
|
})
|
||||||
|
refNumber.value = result.ref
|
||||||
|
confirmed.value = true
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="booking-root">
|
||||||
|
|
||||||
|
<!-- Confirmation ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div v-if="confirmed" class="confirm-screen">
|
||||||
|
<div class="confirm-card">
|
||||||
|
<div class="confirm-icon">✓</div>
|
||||||
|
<h2>Demande envoyée !</h2>
|
||||||
|
<p>Nos techniciens vont examiner votre demande et vous proposer une confirmation de rendez-vous.</p>
|
||||||
|
<div class="ref-box">
|
||||||
|
<span class="ref-label">Numéro de référence</span>
|
||||||
|
<span class="ref-val">{{ refNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="confirm-sub">Vous recevrez une confirmation par SMS ou courriel une fois une date confirmée.</p>
|
||||||
|
<button class="btn-primary" @click="$router.push('/')">Retour à l'accueil</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wizard ───────────────────────────────────────────────────────────────── -->
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="booking-header">
|
||||||
|
<button class="btn-back" @click="step > 1 ? prev() : $router.push('/')" aria-label="Retour">←</button>
|
||||||
|
<div class="header-center">
|
||||||
|
<div class="header-logo">🌐</div>
|
||||||
|
<span>Demande de service</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-pill">{{ step }}/{{ TOTAL_STEPS }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="progress-bar"><div class="progress-fill" :style="{ width: (step / TOTAL_STEPS * 100) + '%' }"></div></div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="booking-body">
|
||||||
|
|
||||||
|
<!-- ── Étape 1 : Sélection du service ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 1" key="s1" class="step-content">
|
||||||
|
<div class="step-title">Quel service avez-vous besoin ?</div>
|
||||||
|
<div class="service-grid">
|
||||||
|
<button v-for="s in SERVICES" :key="s.id"
|
||||||
|
class="service-card"
|
||||||
|
:class="{ selected: selectedService === s.id }"
|
||||||
|
@click="selectedService = s.id; selectedProblem = null">
|
||||||
|
<span class="svc-icon">{{ s.icon }}</span>
|
||||||
|
<span class="svc-label">{{ s.label }}</span>
|
||||||
|
<span class="svc-desc">{{ s.desc }}</span>
|
||||||
|
<span v-if="selectedService === s.id" class="svc-check">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ── Étape 2 : Description du problème ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 2" key="s2" class="step-content">
|
||||||
|
<div class="step-title">Quel est le problème ?</div>
|
||||||
|
<div class="service-label-chip">
|
||||||
|
{{ SERVICES.find(s => s.id === selectedService)?.icon }}
|
||||||
|
{{ SERVICES.find(s => s.id === selectedService)?.label }}
|
||||||
|
</div>
|
||||||
|
<div class="problem-list">
|
||||||
|
<button v-for="p in PROBLEMS[selectedService]" :key="p"
|
||||||
|
class="problem-item"
|
||||||
|
:class="{ selected: selectedProblem === p }"
|
||||||
|
@click="selectedProblem = p">
|
||||||
|
<span class="problem-radio">{{ selectedProblem === p ? '●' : '○' }}</span>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea class="textarea-desc" v-model="description"
|
||||||
|
placeholder="Détails supplémentaires (optionnel)…"
|
||||||
|
rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ── Étape 3 : Adresse ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 3" key="s3" class="step-content">
|
||||||
|
<div class="step-title">Adresse de l'intervention</div>
|
||||||
|
<div class="address-wrap">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-icon">📍</span>
|
||||||
|
<input class="addr-input" type="text"
|
||||||
|
:value="addressQuery"
|
||||||
|
@input="onAddressInput"
|
||||||
|
placeholder="Entrez votre adresse…"
|
||||||
|
autocomplete="off" />
|
||||||
|
<span v-if="addressLoading" class="input-spin">⟳</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="addressSuggestions.length" class="suggestions">
|
||||||
|
<button v-for="f in addressSuggestions" :key="f.id"
|
||||||
|
class="suggestion-item"
|
||||||
|
@click="selectAddress(f)">
|
||||||
|
📍 {{ f.place_name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="address" class="addr-confirmed">
|
||||||
|
✓ {{ address.place_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ── Étape 4 : 3 dates préférées ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 4" key="s4" class="step-content">
|
||||||
|
<div class="step-title">Disponibilités & budget</div>
|
||||||
|
<p class="step-sub">Indiquez jusqu'à 3 dates et les plages horaires qui vous conviennent. Nous confirmerons la meilleure date.</p>
|
||||||
|
|
||||||
|
<!-- Urgence toggle -->
|
||||||
|
<div class="urgency-row">
|
||||||
|
<button class="urgency-btn" :class="{ active: urgency === 'normal' }" @click="urgency = 'normal'">Standard</button>
|
||||||
|
<button class="urgency-btn urgency-urgent" :class="{ active: urgency === 'urgent' }" @click="urgency = 'urgent'">Urgent 🚨</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3 date cards -->
|
||||||
|
<div v-for="(pd, i) in preferredDates" :key="i" class="date-card"
|
||||||
|
:class="{ 'date-card-filled': pd.date && pd.timeSlots.length > 0, 'date-card-active': activeDateIdx === i }"
|
||||||
|
@click="activeDateIdx = i">
|
||||||
|
<div class="date-card-header">
|
||||||
|
<span class="date-priority">{{ ['1re', '2e', '3e'][i] }} priorité</span>
|
||||||
|
<span v-if="pd.date && pd.timeSlots.length > 0" class="date-summary">
|
||||||
|
{{ dateLabel(pd.date) }} · {{ pd.timeSlots.map(id => TIME_SLOTS.find(t => t.id === id)?.label).join(', ') }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="date-empty">Non définie</span>
|
||||||
|
<span class="date-toggle">{{ activeDateIdx === i ? '▲' : '▼' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeDateIdx === i" class="date-card-body">
|
||||||
|
<input type="date" class="date-input" v-model="pd.date" :min="minDate" />
|
||||||
|
|
||||||
|
<div class="slot-label">Plage(s) horaire</div>
|
||||||
|
<div class="slot-checks">
|
||||||
|
<label v-for="slot in TIME_SLOTS" :key="slot.id"
|
||||||
|
class="slot-check-row"
|
||||||
|
:class="{ checked: pd.timeSlots.includes(slot.id) }"
|
||||||
|
@click.stop="toggleSlot(pd, slot.id)">
|
||||||
|
<span class="slot-checkbox">
|
||||||
|
<span v-if="pd.timeSlots.includes(slot.id)" class="slot-checkbox-tick">✓</span>
|
||||||
|
</span>
|
||||||
|
<span class="slot-check-icon">{{ slot.icon }}</span>
|
||||||
|
<span class="slot-check-text">
|
||||||
|
<strong>{{ slot.label }}</strong>
|
||||||
|
<span>{{ slot.sub }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="slot-check-row"
|
||||||
|
:class="{ checked: pd.timeSlots.includes('flexible') }"
|
||||||
|
@click.stop="pd.timeSlots = pd.timeSlots.includes('flexible') ? [] : ['flexible']">
|
||||||
|
<span class="slot-checkbox">
|
||||||
|
<span v-if="pd.timeSlots.includes('flexible')" class="slot-checkbox-tick">✓</span>
|
||||||
|
</span>
|
||||||
|
<span class="slot-check-icon">🕐</span>
|
||||||
|
<span class="slot-check-text">
|
||||||
|
<strong>Je suis flexible</strong>
|
||||||
|
<span>Au choix du technicien</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="validDates.length === 0" class="hint-text">Remplissez au moins une date pour continuer.</p>
|
||||||
|
<p v-else class="hint-ok">✓ {{ validDates.length }} date{{ validDates.length > 1 ? 's' : '' }} sélectionnée{{ validDates.length > 1 ? 's' : '' }}</p>
|
||||||
|
|
||||||
|
<!-- Budget estimé -->
|
||||||
|
<div class="budget-section">
|
||||||
|
<div class="budget-title">Budget estimé</div>
|
||||||
|
<p class="budget-sub">Les techniciens soumettront leur tarif en fonction de votre budget.</p>
|
||||||
|
<div class="budget-grid">
|
||||||
|
<button v-for="b in BUDGET_OPTIONS" :key="b.id"
|
||||||
|
class="budget-btn"
|
||||||
|
:class="{ selected: budgetId === b.id }"
|
||||||
|
@click="budgetId = b.id">
|
||||||
|
{{ b.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!budgetId" class="hint-text">Sélectionnez un budget pour continuer.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ── Étape 5 : Contact + résumé ── -->
|
||||||
|
<transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="step === 5" key="s5" class="step-content">
|
||||||
|
<div class="step-title">Vos coordonnées</div>
|
||||||
|
|
||||||
|
<div class="form-fields">
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Nom complet *</label>
|
||||||
|
<input v-model="contact.name" type="text" placeholder="Jean Tremblay" class="field-input" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Téléphone *</label>
|
||||||
|
<input v-model="contact.phone" type="tel" placeholder="514 555-0000" class="field-input" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Courriel</label>
|
||||||
|
<input v-model="contact.email" type="email" placeholder="jean@exemple.com" class="field-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Résumé -->
|
||||||
|
<div class="summary-box">
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Service</span>
|
||||||
|
<strong>{{ SERVICES.find(s => s.id === selectedService)?.label }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Problème</span>
|
||||||
|
<strong>{{ selectedProblem }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Adresse</span>
|
||||||
|
<strong>{{ address?.place_name || addressQuery }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row" v-for="(pd, i) in validDates" :key="i">
|
||||||
|
<span>Date {{ i + 1 }}</span>
|
||||||
|
<strong>{{ dateLabel(pd.date) }} · {{ pd.timeSlots.map(id => TIME_SLOTS.find(t => t.id === id)?.label).join(', ') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Budget</span>
|
||||||
|
<strong>{{ BUDGET_OPTIONS.find(b => b.id === budgetId)?.label || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row" v-if="urgency === 'urgent'">
|
||||||
|
<span>Urgence</span>
|
||||||
|
<strong style="color:#f43f5e">🚨 Urgent</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
</div><!-- /booking-body -->
|
||||||
|
|
||||||
|
<!-- Footer nav -->
|
||||||
|
<div class="booking-footer">
|
||||||
|
<button v-if="step < TOTAL_STEPS" class="btn-next" :disabled="!canNext" @click="next">
|
||||||
|
Continuer →
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn-next btn-submit" :disabled="!canNext || submitting" @click="submit">
|
||||||
|
{{ submitting ? 'Envoi en cours…' : 'Envoyer la demande ✓' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Tokens ── */
|
||||||
|
.booking-root {
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent2: #818cf8;
|
||||||
|
--bg: #0f1117;
|
||||||
|
--surface: rgba(255,255,255,0.04);
|
||||||
|
--surface2: rgba(255,255,255,0.07);
|
||||||
|
--border: rgba(255,255,255,0.09);
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--text2: #94a3b8;
|
||||||
|
--green: #10b981;
|
||||||
|
--red: #f43f5e;
|
||||||
|
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.booking-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem 0.75rem;
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
background: rgba(15,17,23,0.9); backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-back { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 8px; width: 36px; height: 36px; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.btn-back:hover { border-color: var(--accent); }
|
||||||
|
.header-center { display: flex; align-items: center; gap: 0.5rem; font-weight: 700; font-size: 0.95rem; }
|
||||||
|
.header-logo { font-size: 1.3rem; }
|
||||||
|
.step-pill { background: rgba(99,102,241,0.2); color: var(--accent2); border: 1px solid rgba(99,102,241,0.3); border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.75rem; font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── Progress ── */
|
||||||
|
.progress-bar { height: 3px; background: var(--border); }
|
||||||
|
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width 0.4s ease; border-radius: 0 2px 2px 0; }
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.booking-body { flex: 1; overflow-y: auto; padding: 1.5rem 1.25rem 6rem; }
|
||||||
|
.step-content { animation: fadeUp 0.25s ease; }
|
||||||
|
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.step-title { font-size: 1.35rem; font-weight: 800; margin-bottom: 0.35rem; }
|
||||||
|
.step-sub { color: var(--text2); font-size: 0.85rem; margin-bottom: 1.25rem; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* ── Service grid ── */
|
||||||
|
.service-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-top: 1.25rem; }
|
||||||
|
.service-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 14px; padding: 1.1rem 0.9rem; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: 0.2rem; transition: all 0.18s; position: relative; }
|
||||||
|
.service-card:hover { border-color: rgba(99,102,241,0.4); background: var(--surface2); }
|
||||||
|
.service-card.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); box-shadow: 0 0 0 3px rgba(99,102,241,0.18); }
|
||||||
|
.svc-icon { font-size: 1.8rem; margin-bottom: 0.25rem; }
|
||||||
|
.svc-label { font-size: 0.95rem; font-weight: 700; }
|
||||||
|
.svc-desc { font-size: 0.72rem; color: var(--text2); }
|
||||||
|
.svc-check { position: absolute; top: 0.75rem; right: 0.75rem; background: var(--accent); color: white; border-radius: 50%; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 800; }
|
||||||
|
|
||||||
|
/* ── Problem list ── */
|
||||||
|
.service-label-chip { display: inline-flex; align-items: center; gap: 0.4rem; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.25); color: var(--accent2); border-radius: 20px; padding: 0.3rem 0.85rem; font-size: 0.82rem; font-weight: 600; margin-bottom: 1.25rem; }
|
||||||
|
.problem-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.problem-item { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; text-align: left; font-size: 0.88rem; display: flex; align-items: center; gap: 0.75rem; transition: all 0.15s; }
|
||||||
|
.problem-item:hover { border-color: rgba(99,102,241,0.35); }
|
||||||
|
.problem-item.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); color: white; }
|
||||||
|
.problem-radio { font-size: 1rem; color: var(--accent); flex-shrink: 0; }
|
||||||
|
.textarea-desc { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.85rem 1rem; color: var(--text); font-size: 0.88rem; resize: vertical; font-family: inherit; box-sizing: border-box; }
|
||||||
|
.textarea-desc:focus { border-color: var(--accent); outline: none; }
|
||||||
|
|
||||||
|
/* ── Address ── */
|
||||||
|
.address-wrap { margin-top: 1rem; }
|
||||||
|
.input-group { position: relative; display: flex; align-items: center; }
|
||||||
|
.input-icon { position: absolute; left: 0.9rem; font-size: 1rem; pointer-events: none; }
|
||||||
|
.addr-input { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 12px; padding: 0.85rem 3rem 0.85rem 2.5rem; color: var(--text); font-size: 0.9rem; box-sizing: border-box; }
|
||||||
|
.addr-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.input-spin { position: absolute; right: 0.9rem; animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.suggestions { background: #1a1d27; border: 1px solid var(--border); border-radius: 12px; margin-top: 0.5rem; overflow: hidden; }
|
||||||
|
.suggestion-item { width: 100%; background: none; border: none; border-bottom: 1px solid var(--border); color: var(--text); padding: 0.75rem 1rem; cursor: pointer; text-align: left; font-size: 0.82rem; transition: background 0.12s; }
|
||||||
|
.suggestion-item:last-child { border-bottom: none; }
|
||||||
|
.suggestion-item:hover { background: var(--surface2); }
|
||||||
|
.addr-confirmed { margin-top: 0.85rem; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 0.75rem 1rem; font-size: 0.85rem; color: var(--green); }
|
||||||
|
|
||||||
|
/* ── Dates ── */
|
||||||
|
.urgency-row { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; }
|
||||||
|
.urgency-btn { flex: 1; background: var(--surface); border: 1.5px solid var(--border); color: var(--text2); border-radius: 10px; padding: 0.65rem; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.15s; }
|
||||||
|
.urgency-btn.active { border-color: var(--accent); background: rgba(99,102,241,0.12); color: var(--text); }
|
||||||
|
.urgency-urgent.active { border-color: var(--red); background: rgba(244,63,94,0.1); color: var(--red); }
|
||||||
|
.date-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 14px; margin-bottom: 0.75rem; overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
|
||||||
|
.date-card:hover { border-color: rgba(99,102,241,0.35); }
|
||||||
|
.date-card-filled { border-color: rgba(16,185,129,0.4); }
|
||||||
|
.date-card-active { border-color: var(--accent); }
|
||||||
|
.date-card-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.9rem 1rem; }
|
||||||
|
.date-priority { background: rgba(99,102,241,0.15); color: var(--accent2); border-radius: 6px; padding: 0.15rem 0.5rem; font-size: 0.7rem; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.date-summary { flex: 1; font-size: 0.82rem; font-weight: 600; }
|
||||||
|
.date-empty { flex: 1; font-size: 0.82rem; color: var(--text2); font-style: italic; }
|
||||||
|
.date-toggle { color: var(--text2); font-size: 0.65rem; }
|
||||||
|
.date-card-body { padding: 0 1rem 1rem; border-top: 1px solid var(--border); }
|
||||||
|
.date-input { width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem 0.85rem; color: var(--text); font-size: 0.9rem; margin: 0.75rem 0; box-sizing: border-box; }
|
||||||
|
.date-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.hint-text { font-size: 0.8rem; color: var(--text2); text-align: center; margin-top: 0.5rem; }
|
||||||
|
.hint-ok { font-size: 0.8rem; color: var(--green); text-align: center; margin-top: 0.5rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Slot checkboxes ── */
|
||||||
|
.slot-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text2); margin-bottom: 0.5rem; }
|
||||||
|
.slot-checks { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.slot-check-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.75rem; border-radius: 10px; border: 1.5px solid var(--border); cursor: pointer; background: var(--surface2); transition: all 0.15s; user-select: none; }
|
||||||
|
.slot-check-row:hover { border-color: rgba(99,102,241,0.3); }
|
||||||
|
.slot-check-row.checked { border-color: var(--accent); background: rgba(99,102,241,0.1); }
|
||||||
|
.slot-checkbox { width: 20px; height: 20px; border-radius: 5px; border: 1.5px solid var(--border); flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: var(--surface); }
|
||||||
|
.slot-check-row.checked .slot-checkbox { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.slot-checkbox-tick { color: white; font-size: 0.7rem; font-weight: 800; }
|
||||||
|
.slot-check-icon { font-size: 1.1rem; }
|
||||||
|
.slot-check-text { display: flex; flex-direction: column; gap: 0.05rem; }
|
||||||
|
.slot-check-text strong { font-size: 0.85rem; color: var(--text); }
|
||||||
|
.slot-check-text span { font-size: 0.7rem; color: var(--text2); }
|
||||||
|
|
||||||
|
/* ── Budget ── */
|
||||||
|
.budget-section { margin-top: 1.5rem; border-top: 1px solid var(--border); padding-top: 1.25rem; }
|
||||||
|
.budget-title { font-size: 1rem; font-weight: 800; margin-bottom: 0.25rem; }
|
||||||
|
.budget-sub { font-size: 0.8rem; color: var(--text2); margin-bottom: 0.85rem; }
|
||||||
|
.budget-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||||
|
.budget-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 0.5rem; cursor: pointer; color: var(--text); font-size: 0.9rem; font-weight: 700; transition: all 0.15s; }
|
||||||
|
.budget-btn:hover { border-color: rgba(99,102,241,0.35); }
|
||||||
|
.budget-btn.selected { border-color: var(--accent); background: rgba(99,102,241,0.12); color: #a5b4fc; }
|
||||||
|
|
||||||
|
/* ── Contact + résumé ── */
|
||||||
|
.form-fields { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
.field-group { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.field-group label { font-size: 0.78rem; font-weight: 700; color: var(--text2); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.field-input { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; color: var(--text); font-size: 0.9rem; }
|
||||||
|
.field-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.summary-box { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
|
||||||
|
.summary-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.82rem; }
|
||||||
|
.summary-row:last-child { border-bottom: none; }
|
||||||
|
.summary-row span { color: var(--text2); flex-shrink: 0; }
|
||||||
|
.summary-row strong { text-align: right; }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.booking-footer {
|
||||||
|
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 100%; max-width: 560px; padding: 1rem 1.25rem;
|
||||||
|
background: linear-gradient(to top, var(--bg) 70%, transparent);
|
||||||
|
}
|
||||||
|
.btn-next { width: 100%; background: var(--accent); border: none; color: white; border-radius: 14px; padding: 1rem; font-size: 1rem; font-weight: 700; cursor: pointer; transition: opacity 0.15s; }
|
||||||
|
.btn-next:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.btn-next:hover:not(:disabled) { opacity: 0.88; }
|
||||||
|
.btn-submit { background: linear-gradient(135deg, var(--accent), #a855f7); }
|
||||||
|
|
||||||
|
/* ── Confirmation ── */
|
||||||
|
.confirm-screen { min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
||||||
|
.confirm-card { text-align: center; max-width: 380px; }
|
||||||
|
.confirm-icon { width: 72px; height: 72px; background: rgba(16,185,129,0.15); border: 2px solid var(--green); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2rem; color: var(--green); margin: 0 auto 1.5rem; }
|
||||||
|
.confirm-card h2 { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.75rem; }
|
||||||
|
.confirm-card p { color: var(--text2); line-height: 1.6; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||||||
|
.ref-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1.25rem; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.ref-label { font-size: 0.72rem; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.ref-val { font-size: 1.5rem; font-weight: 800; color: var(--accent2); letter-spacing: 0.08em; }
|
||||||
|
.confirm-sub { font-size: 0.8rem; color: var(--text2); margin-bottom: 2rem; }
|
||||||
|
.btn-primary { background: var(--accent); border: none; color: white; border-radius: 12px; padding: 0.85rem 2rem; font-size: 0.95rem; font-weight: 700; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Transitions ── */
|
||||||
|
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.22s ease; }
|
||||||
|
.fade-up-enter-from { opacity: 0; transform: translateY(12px); }
|
||||||
|
.fade-up-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||||
|
</style>
|
||||||
716
apps/dispatch/src/pages/ContractorPage.vue
Normal file
|
|
@ -0,0 +1,716 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { registerContractor } from 'src/api/contractor'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const ALL_SERVICES = [
|
||||||
|
{ id: 'informatique', icon: '💻', label: 'Informatique' },
|
||||||
|
{ id: 'formatage', icon: '🖥️', label: 'Formatage PC' },
|
||||||
|
{ id: 'nettoyage', icon: '🧹', label: 'Nettoyage' },
|
||||||
|
{ id: 'camera', icon: '📷', label: 'Caméras sécurité' },
|
||||||
|
{ id: 'plomberie', icon: '🔧', label: 'Plomberie' },
|
||||||
|
{ id: 'electricite', icon: '⚡', label: 'Électricité' },
|
||||||
|
{ id: 'climatisation', icon: '❄️', label: 'Climatisation' },
|
||||||
|
{ id: 'telephone', icon: '📱', label: 'Téléphones' },
|
||||||
|
{ id: 'serrurerie', icon: '🔒', label: 'Serrurerie' },
|
||||||
|
{ id: 'peinture', icon: '🎨', label: 'Peinture' },
|
||||||
|
{ id: 'jardinage', icon: '🌿', label: 'Entretien extérieur' },
|
||||||
|
{ id: 'autre', icon: '🔨', label: 'Autre' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
{ id: 'mon', label: 'Lun' },
|
||||||
|
{ id: 'tue', label: 'Mar' },
|
||||||
|
{ id: 'wed', label: 'Mer' },
|
||||||
|
{ id: 'thu', label: 'Jeu' },
|
||||||
|
{ id: 'fri', label: 'Ven' },
|
||||||
|
{ id: 'sat', label: 'Sam' },
|
||||||
|
{ id: 'sun', label: 'Dim' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TOTAL_STEPS = 4
|
||||||
|
const step = ref(1)
|
||||||
|
|
||||||
|
// ── Step 1 — Profil ──────────────────────────────────────────────────────────
|
||||||
|
const profile = ref({
|
||||||
|
firstname: '',
|
||||||
|
lastname: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
company: '',
|
||||||
|
license: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Step 2 — Services ────────────────────────────────────────────────────────
|
||||||
|
// selectedServices: { [id]: { rate: '', rateType: 'hourly' } }
|
||||||
|
const selectedServices = ref({})
|
||||||
|
|
||||||
|
function toggleService (svc) {
|
||||||
|
if (selectedServices.value[svc.id]) {
|
||||||
|
const copy = { ...selectedServices.value }
|
||||||
|
delete copy[svc.id]
|
||||||
|
selectedServices.value = copy
|
||||||
|
} else {
|
||||||
|
selectedServices.value = {
|
||||||
|
...selectedServices.value,
|
||||||
|
[svc.id]: { rate: '', rateType: 'hourly' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function isSelected (id) { return !!selectedServices.value[id] }
|
||||||
|
|
||||||
|
const selectedServiceList = computed(() =>
|
||||||
|
ALL_SERVICES
|
||||||
|
.filter(s => selectedServices.value[s.id])
|
||||||
|
.map(s => ({
|
||||||
|
...s,
|
||||||
|
rate: selectedServices.value[s.id].rate,
|
||||||
|
rateType: selectedServices.value[s.id].rateType,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Step 3 — Zone & disponibilité ────────────────────────────────────────────
|
||||||
|
const availability = ref({
|
||||||
|
city: '',
|
||||||
|
radius: '25km',
|
||||||
|
days: ['mon','tue','wed','thu','fri'],
|
||||||
|
urgent: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleDay (id) {
|
||||||
|
const days = availability.value.days
|
||||||
|
if (days.includes(id)) {
|
||||||
|
availability.value.days = days.filter(d => d !== id)
|
||||||
|
} else {
|
||||||
|
availability.value.days = [...days, id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit ───────────────────────────────────────────────────────────────────
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitError = ref('')
|
||||||
|
const contractorRef = ref('')
|
||||||
|
|
||||||
|
async function submit () {
|
||||||
|
submitting.value = true
|
||||||
|
submitError.value = ''
|
||||||
|
try {
|
||||||
|
const ref = await registerContractor({
|
||||||
|
profile: profile.value,
|
||||||
|
services: selectedServiceList.value,
|
||||||
|
availability: availability.value,
|
||||||
|
})
|
||||||
|
contractorRef.value = ref
|
||||||
|
step.value = 5
|
||||||
|
} catch (e) {
|
||||||
|
submitError.value = e.message || 'Erreur lors de la soumission.'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ───────────────────────────────────────────────────────────────
|
||||||
|
const canNext = computed(() => {
|
||||||
|
if (step.value === 1) {
|
||||||
|
const p = profile.value
|
||||||
|
return p.firstname.trim().length >= 2
|
||||||
|
&& p.lastname.trim().length >= 2
|
||||||
|
&& p.phone.replace(/\D/g, '').length >= 10
|
||||||
|
&& p.email.includes('@')
|
||||||
|
}
|
||||||
|
if (step.value === 2) return selectedServiceList.value.length >= 1
|
||||||
|
&& selectedServiceList.value.every(s => s.rate.trim() !== '')
|
||||||
|
if (step.value === 3) return availability.value.city.trim().length >= 2
|
||||||
|
&& availability.value.days.length >= 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
function next () { if (canNext.value && step.value < TOTAL_STEPS) step.value++ }
|
||||||
|
function prev () { if (step.value > 1) step.value-- }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ct-root">
|
||||||
|
|
||||||
|
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="ct-header">
|
||||||
|
<button class="btn-back" @click="router.push('/')">← Retour</button>
|
||||||
|
<div class="ct-brand">Dispatch</div>
|
||||||
|
<div v-if="step <= TOTAL_STEPS" class="step-dots">
|
||||||
|
<span
|
||||||
|
v-for="i in TOTAL_STEPS"
|
||||||
|
:key="i"
|
||||||
|
class="dot"
|
||||||
|
:class="{ active: step === i, done: step > i }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Hero intro (before step 1) ── not shown, header serves this role -->
|
||||||
|
|
||||||
|
<!-- ── Body ───────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="ct-body">
|
||||||
|
|
||||||
|
<!-- Step 1 — Profil -->
|
||||||
|
<div v-if="step === 1" class="step-panel">
|
||||||
|
<div class="step-eyebrow">Étape 1 sur {{ TOTAL_STEPS }}</div>
|
||||||
|
<h1 class="step-title">Votre profil</h1>
|
||||||
|
<p class="step-sub">
|
||||||
|
Rejoignez notre réseau de techniciens et sous-traitants.<br>
|
||||||
|
Nous vous contactons sous 24h après révision de votre profil.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Prénom *</label>
|
||||||
|
<input v-model="profile.firstname" type="text" placeholder="Jean" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Nom *</label>
|
||||||
|
<input v-model="profile.lastname" type="text" placeholder="Tremblay" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Téléphone *</label>
|
||||||
|
<input v-model="profile.phone" type="tel" placeholder="514-555-0123" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Courriel *</label>
|
||||||
|
<input v-model="profile.email" type="email" placeholder="jean@exemple.com" />
|
||||||
|
</div>
|
||||||
|
<div class="field span2">
|
||||||
|
<label>Entreprise (optionnel)</label>
|
||||||
|
<input v-model="profile.company" type="text" placeholder="Technologies XYZ inc." />
|
||||||
|
</div>
|
||||||
|
<div class="field span2">
|
||||||
|
<label>Numéro RBQ / Licence (optionnel)</label>
|
||||||
|
<input v-model="profile.license" type="text" placeholder="8301-1234-56" />
|
||||||
|
<span class="field-hint">Requis pour plomberie, électricité et certains travaux de construction</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 — Services -->
|
||||||
|
<div v-if="step === 2" class="step-panel">
|
||||||
|
<div class="step-eyebrow">Étape 2 sur {{ TOTAL_STEPS }}</div>
|
||||||
|
<h1 class="step-title">Vos services et tarifs</h1>
|
||||||
|
<p class="step-sub">Sélectionnez les services que vous offrez et indiquez votre tarif pour chacun</p>
|
||||||
|
|
||||||
|
<div class="service-grid">
|
||||||
|
<button
|
||||||
|
v-for="s in ALL_SERVICES"
|
||||||
|
:key="s.id"
|
||||||
|
class="service-chip"
|
||||||
|
:class="{ selected: isSelected(s.id) }"
|
||||||
|
@click="toggleService(s)"
|
||||||
|
>
|
||||||
|
<span>{{ s.icon }}</span>
|
||||||
|
<span class="chip-label">{{ s.label }}</span>
|
||||||
|
<span v-if="isSelected(s.id)" class="chip-check">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rate inputs for selected services -->
|
||||||
|
<div v-if="selectedServiceList.length" class="rates-section">
|
||||||
|
<div class="rates-title">Tarifs pour les services sélectionnés</div>
|
||||||
|
<div
|
||||||
|
v-for="s in selectedServiceList"
|
||||||
|
:key="s.id"
|
||||||
|
class="rate-row"
|
||||||
|
>
|
||||||
|
<div class="rate-svc">
|
||||||
|
<span class="rate-icon">{{ s.icon }}</span>
|
||||||
|
<span class="rate-label">{{ s.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rate-inputs">
|
||||||
|
<input
|
||||||
|
v-model="selectedServices[s.id].rate"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="75"
|
||||||
|
class="rate-amount"
|
||||||
|
/>
|
||||||
|
<span class="rate-currency">$</span>
|
||||||
|
<select v-model="selectedServices[s.id].rateType" class="rate-type">
|
||||||
|
<option value="hourly">/ heure</option>
|
||||||
|
<option value="flat">forfait</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!selectedServiceList.length" class="hint-box">
|
||||||
|
Sélectionnez au moins un service ci-dessus
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3 — Zone & disponibilité -->
|
||||||
|
<div v-if="step === 3" class="step-panel">
|
||||||
|
<div class="step-eyebrow">Étape 3 sur {{ TOTAL_STEPS }}</div>
|
||||||
|
<h1 class="step-title">Zone et disponibilité</h1>
|
||||||
|
<p class="step-sub">Définissez où vous opérez et quand vous êtes disponible</p>
|
||||||
|
|
||||||
|
<div class="zone-section">
|
||||||
|
<div class="field">
|
||||||
|
<label>Ville principale *</label>
|
||||||
|
<input v-model="availability.city" type="text" placeholder="Montréal, Laval, Longueuil…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Rayon d'intervention</label>
|
||||||
|
<div class="radius-group">
|
||||||
|
<button
|
||||||
|
v-for="r in ['10km','25km','50km','Province']"
|
||||||
|
:key="r"
|
||||||
|
class="radius-btn"
|
||||||
|
:class="{ selected: availability.radius === r }"
|
||||||
|
@click="availability.radius = r"
|
||||||
|
>{{ r }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Jours disponibles *</label>
|
||||||
|
<div class="days-group">
|
||||||
|
<button
|
||||||
|
v-for="d in DAYS"
|
||||||
|
:key="d.id"
|
||||||
|
class="day-btn"
|
||||||
|
:class="{ selected: availability.days.includes(d.id) }"
|
||||||
|
@click="toggleDay(d.id)"
|
||||||
|
>{{ d.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="urgent-row">
|
||||||
|
<input type="checkbox" v-model="availability.urgent" />
|
||||||
|
<span>Disponible pour les urgences (interventions rapides)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4 — Révision -->
|
||||||
|
<div v-if="step === 4" class="step-panel">
|
||||||
|
<div class="step-eyebrow">Étape 4 sur {{ TOTAL_STEPS }} — Révision</div>
|
||||||
|
<h1 class="step-title">Confirmer votre inscription</h1>
|
||||||
|
<p class="step-sub">Vérifiez vos informations avant de soumettre</p>
|
||||||
|
|
||||||
|
<div class="review-card">
|
||||||
|
<div class="review-section">
|
||||||
|
<div class="review-section-title">Profil</div>
|
||||||
|
<div class="review-row"><span>Nom</span><strong>{{ profile.firstname }} {{ profile.lastname }}</strong></div>
|
||||||
|
<div class="review-row"><span>Téléphone</span><strong>{{ profile.phone }}</strong></div>
|
||||||
|
<div class="review-row"><span>Courriel</span><strong>{{ profile.email }}</strong></div>
|
||||||
|
<div v-if="profile.company" class="review-row"><span>Entreprise</span><strong>{{ profile.company }}</strong></div>
|
||||||
|
<div v-if="profile.license" class="review-row"><span>Licence</span><strong>{{ profile.license }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="review-section">
|
||||||
|
<div class="review-section-title">Services offerts</div>
|
||||||
|
<div v-for="s in selectedServiceList" :key="s.id" class="review-row">
|
||||||
|
<span>{{ s.icon }} {{ s.label }}</span>
|
||||||
|
<strong>{{ s.rate }} $ / {{ s.rateType === 'hourly' ? 'heure' : 'forfait' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="review-section">
|
||||||
|
<div class="review-section-title">Zone et disponibilité</div>
|
||||||
|
<div class="review-row"><span>Ville</span><strong>{{ availability.city }}</strong></div>
|
||||||
|
<div class="review-row"><span>Rayon</span><strong>{{ availability.radius }}</strong></div>
|
||||||
|
<div class="review-row">
|
||||||
|
<span>Jours</span>
|
||||||
|
<strong>
|
||||||
|
{{ DAYS.filter(d => availability.days.includes(d.id)).map(d => d.label).join(', ') }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="availability.urgent" class="review-row">
|
||||||
|
<span>Urgences</span><strong>Disponible</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="submitError" class="submit-error">{{ submitError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5 — Confirmation -->
|
||||||
|
<div v-if="step === 5" class="step-panel step-confirm">
|
||||||
|
<div class="confirm-anim">🎉</div>
|
||||||
|
<h1 class="step-title">Candidature reçue !</h1>
|
||||||
|
<p class="step-sub">
|
||||||
|
Votre profil est en cours de révision.<br>
|
||||||
|
Un responsable vous contactera sous 24h.
|
||||||
|
</p>
|
||||||
|
<div class="confirm-ref">
|
||||||
|
Référence : <strong>{{ contractorRef }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="next-steps">
|
||||||
|
<div class="next-step-title">Prochaines étapes</div>
|
||||||
|
<div class="next-step-item">
|
||||||
|
<span class="ns-num">1</span>
|
||||||
|
<span>Vérification de votre profil et de vos certifications</span>
|
||||||
|
</div>
|
||||||
|
<div class="next-step-item">
|
||||||
|
<span class="ns-num">2</span>
|
||||||
|
<span>Entretien téléphonique avec notre équipe</span>
|
||||||
|
</div>
|
||||||
|
<div class="next-step-item">
|
||||||
|
<span class="ns-num">3</span>
|
||||||
|
<span>Activation de votre compte et réception de vos premiers jobs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary-lg" @click="router.push('/')">Retour à l'accueil</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /ct-body -->
|
||||||
|
|
||||||
|
<!-- ── Footer nav ──────────────────────────────────────────────────────── -->
|
||||||
|
<div v-if="step <= TOTAL_STEPS" class="ct-footer">
|
||||||
|
<button v-if="step > 1" class="btn-prev" @click="prev">← Précédent</button>
|
||||||
|
<div v-else class="footer-spacer" />
|
||||||
|
|
||||||
|
<div class="footer-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: ((step - 1) / TOTAL_STEPS * 100) + '%' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="step < TOTAL_STEPS"
|
||||||
|
class="btn-next"
|
||||||
|
:disabled="!canNext"
|
||||||
|
@click="next"
|
||||||
|
>
|
||||||
|
Suivant →
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-submit"
|
||||||
|
:disabled="!canNext || submitting"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Envoi…' : 'Soumettre mon profil' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Root ── */
|
||||||
|
.ct-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
color: var(--text-primary, #f1f5f9);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.ct-header {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
background: rgba(15,17,23,0.92);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.ct-brand {
|
||||||
|
font-size: 1rem; font-weight: 800;
|
||||||
|
color: #10b981; flex: 1;
|
||||||
|
}
|
||||||
|
.btn-back {
|
||||||
|
background: none; border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
border-radius: 6px; padding: 0.3rem 0.75rem;
|
||||||
|
cursor: pointer; font-size: 0.8rem; font-weight: 600;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-back:hover { color: var(--text-primary, #f1f5f9); border-color: #10b981; }
|
||||||
|
.step-dots { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--border, rgba(255,255,255,0.12));
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.dot.active { background: #10b981; width: 22px; border-radius: 4px; }
|
||||||
|
.dot.done { background: #10b981; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.ct-body {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 2rem 1.5rem 6rem;
|
||||||
|
max-width: 680px; margin: 0 auto; width: 100%;
|
||||||
|
}
|
||||||
|
.step-panel { animation: fadeUp 0.25s ease; }
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.step-eyebrow {
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em; color: #10b981; margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.step-title { font-size: 1.6rem; font-weight: 800; margin: 0 0 0.4rem; line-height: 1.2; }
|
||||||
|
.step-sub {
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
font-size: 0.92rem; margin: 0 0 1.75rem; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step 1 — Form grid ── */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.span2 { grid-column: span 2; }
|
||||||
|
|
||||||
|
/* ── Step 2 — Service chips ── */
|
||||||
|
.service-grid {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.service-chip {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.85rem; border-radius: 99px;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 2px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
cursor: pointer; font-size: 0.82rem;
|
||||||
|
transition: all 0.18s; color: var(--text-primary, #f1f5f9);
|
||||||
|
}
|
||||||
|
.service-chip:hover { border-color: rgba(16,185,129,0.4); }
|
||||||
|
.service-chip.selected {
|
||||||
|
border-color: #10b981;
|
||||||
|
background: rgba(16,185,129,0.1);
|
||||||
|
}
|
||||||
|
.chip-label { font-weight: 600; }
|
||||||
|
.chip-check { color: #10b981; font-weight: 700; font-size: 0.7rem; }
|
||||||
|
|
||||||
|
.rates-section {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.rates-title {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; color: var(--text-secondary, #94a3b8);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
.rate-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 1rem; padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
|
||||||
|
}
|
||||||
|
.rate-row:last-child { border-bottom: none; }
|
||||||
|
.rate-svc { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.rate-icon { font-size: 1rem; }
|
||||||
|
.rate-label { font-size: 0.85rem; font-weight: 600; }
|
||||||
|
.rate-inputs { display: flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.rate-amount {
|
||||||
|
width: 72px; background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 6px; color: var(--text-primary, #f1f5f9);
|
||||||
|
padding: 0.35rem 0.5rem; font-size: 0.85rem; text-align: right;
|
||||||
|
outline: none; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.rate-amount:focus { border-color: #10b981; }
|
||||||
|
.rate-currency { font-size: 0.82rem; color: var(--text-secondary, #94a3b8); }
|
||||||
|
.rate-type {
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 6px; color: var(--text-primary, #f1f5f9);
|
||||||
|
padding: 0.35rem 0.5rem; font-size: 0.8rem; cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-box {
|
||||||
|
text-align: center; padding: 2rem;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
font-size: 0.88rem; font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step 3 — Zone ── */
|
||||||
|
.zone-section { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||||
|
.radius-group { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.radius-btn {
|
||||||
|
padding: 0.45rem 1rem; border-radius: 8px;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 2px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-primary, #f1f5f9); cursor: pointer;
|
||||||
|
font-size: 0.82rem; font-weight: 600; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.radius-btn:hover { border-color: rgba(16,185,129,0.4); }
|
||||||
|
.radius-btn.selected { border-color: #10b981; background: rgba(16,185,129,0.1); }
|
||||||
|
|
||||||
|
.days-group { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.day-btn {
|
||||||
|
width: 44px; height: 44px; border-radius: 8px;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 2px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-primary, #f1f5f9); cursor: pointer;
|
||||||
|
font-size: 0.8rem; font-weight: 700; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.day-btn:hover { border-color: rgba(16,185,129,0.4); }
|
||||||
|
.day-btn.selected { border-color: #10b981; background: rgba(16,185,129,0.1); color: #10b981; }
|
||||||
|
|
||||||
|
.urgent-row {
|
||||||
|
display: flex; align-items: center; gap: 0.65rem;
|
||||||
|
cursor: pointer; font-size: 0.88rem; color: var(--text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
.urgent-row input { accent-color: #10b981; width: 16px; height: 16px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Step 4 — Review ── */
|
||||||
|
.review-card {
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.review-section { border-bottom: 1px solid var(--border, rgba(255,255,255,0.08)); }
|
||||||
|
.review-section:last-child { border-bottom: none; }
|
||||||
|
.review-section-title {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; color: var(--text-secondary, #94a3b8);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
.review-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 0.6rem 1rem; font-size: 0.82rem;
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
|
||||||
|
}
|
||||||
|
.review-row:last-child { border-bottom: none; }
|
||||||
|
.review-row span { color: var(--text-secondary, #94a3b8); }
|
||||||
|
.review-row strong { color: var(--text-primary, #f1f5f9); }
|
||||||
|
|
||||||
|
/* ── Step 5 — Confirm ── */
|
||||||
|
.step-confirm { text-align: center; padding-top: 2rem; }
|
||||||
|
.confirm-anim {
|
||||||
|
font-size: 4rem; margin-bottom: 1rem;
|
||||||
|
animation: popIn 0.4s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
}
|
||||||
|
@keyframes popIn {
|
||||||
|
from { transform: scale(0.4); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
.confirm-ref {
|
||||||
|
display: inline-block; margin: 1.5rem auto;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 8px; padding: 0.65rem 1.25rem;
|
||||||
|
font-size: 0.88rem; color: var(--text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
.confirm-ref strong { color: #10b981; font-size: 1rem; }
|
||||||
|
|
||||||
|
.next-steps {
|
||||||
|
text-align: left; margin: 1.5rem 0 2rem;
|
||||||
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.next-step-title {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: var(--sidebar-bg, #161b27);
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; color: var(--text-secondary, #94a3b8);
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
.next-step-item {
|
||||||
|
display: flex; align-items: flex-start; gap: 0.9rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
|
||||||
|
font-size: 0.85rem; color: var(--text-primary, #f1f5f9);
|
||||||
|
}
|
||||||
|
.next-step-item:last-child { border-bottom: none; }
|
||||||
|
.ns-num {
|
||||||
|
flex-shrink: 0; width: 22px; height: 22px;
|
||||||
|
background: #10b981; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.7rem; font-weight: 700; color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-error {
|
||||||
|
margin-top: 0.75rem; padding: 0.65rem 0.9rem;
|
||||||
|
background: rgba(244,63,94,0.08);
|
||||||
|
border: 1px solid rgba(244,63,94,0.25);
|
||||||
|
border-radius: 8px; font-size: 0.82rem; color: #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.ct-footer {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
background: rgba(15,17,23,0.96);
|
||||||
|
border-top: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.footer-spacer { flex: 0 0 80px; }
|
||||||
|
.footer-progress { flex: 1; }
|
||||||
|
.progress-bar {
|
||||||
|
height: 3px; background: var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%; background: #10b981;
|
||||||
|
border-radius: 2px; transition: width 0.35s ease;
|
||||||
|
}
|
||||||
|
.btn-prev {
|
||||||
|
flex: 0 0 auto; background: none;
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
border-radius: 8px; padding: 0.55rem 1rem;
|
||||||
|
cursor: pointer; font-size: 0.82rem; font-weight: 600;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-prev:hover { color: var(--text-primary, #f1f5f9); border-color: rgba(255,255,255,0.2); }
|
||||||
|
.btn-next {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: #10b981; border: none; color: white;
|
||||||
|
border-radius: 8px; padding: 0.55rem 1.25rem;
|
||||||
|
cursor: pointer; font-size: 0.88rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-next:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.btn-next:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.btn-submit {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: #10b981; border: none; color: white;
|
||||||
|
border-radius: 8px; padding: 0.6rem 1.5rem;
|
||||||
|
cursor: pointer; font-size: 0.88rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-submit:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.btn-submit:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.btn-primary-lg {
|
||||||
|
background: #10b981; border: none; color: white;
|
||||||
|
border-radius: 10px; padding: 0.75rem 2rem;
|
||||||
|
cursor: pointer; font-size: 0.95rem; font-weight: 700;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary-lg:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ── Shared field styles ── */
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.field label {
|
||||||
|
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em; color: var(--text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
.field input, .field select, .field textarea {
|
||||||
|
background: var(--bg, #0f1117);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.08));
|
||||||
|
border-radius: 8px; color: var(--text-primary, #f1f5f9);
|
||||||
|
padding: 0.6rem 0.85rem; font-size: 0.88rem;
|
||||||
|
font-family: inherit; outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus { border-color: #10b981; }
|
||||||
|
.field-hint { font-size: 0.7rem; color: var(--text-secondary, #64748b); }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
|
.span2 { grid-column: span 1; }
|
||||||
|
.step-title { font-size: 1.3rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1959
apps/dispatch/src/pages/DispatchPage.vue
Normal file
1626
apps/dispatch/src/pages/DispatchV2Page.vue
Normal file
700
apps/dispatch/src/pages/MobilePage.vue
Normal file
|
|
@ -0,0 +1,700 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
|
import { useDispatchStore } from 'src/stores/dispatch'
|
||||||
|
import { fetchTechnicians } from 'src/api/dispatch'
|
||||||
|
import { createEquipmentInstall } from 'src/api/service-request'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const store = useDispatchStore()
|
||||||
|
|
||||||
|
// ── UI state ────────────────────────────────────────────────────────────────
|
||||||
|
const phase = ref('loading') // 'loading' | 'login' | 'select-tech' | 'jobs'
|
||||||
|
const tab = ref('jobs') // 'jobs' | 'equipment' | 'map' | 'profile'
|
||||||
|
const showCompleted = ref(false)
|
||||||
|
const showToast = ref(false)
|
||||||
|
const toastMsg = ref('')
|
||||||
|
const detailJob = ref(null)
|
||||||
|
|
||||||
|
// Login form
|
||||||
|
const loginUser = ref('')
|
||||||
|
const loginPass = ref('')
|
||||||
|
const showPwd = ref(false)
|
||||||
|
|
||||||
|
// Tech selector
|
||||||
|
const techList = ref([])
|
||||||
|
const selTechId = ref('')
|
||||||
|
const selTech = computed(() => techList.value.find(t => t.name === selTechId.value) || null)
|
||||||
|
const techName = computed(() => selTech.value?.fullName || selTech.value?.name || '')
|
||||||
|
|
||||||
|
const COLORS = ['#6366f1','#10b981','#f59e0b','#8b5cf6','#06b6d4','#f43f5e','#f97316','#14b8a6']
|
||||||
|
const techColor = computed(() => {
|
||||||
|
const idx = techList.value.indexOf(selTech.value)
|
||||||
|
return COLORS[idx >= 0 ? idx % COLORS.length : 0]
|
||||||
|
})
|
||||||
|
const initials = computed(() =>
|
||||||
|
(techName.value || 'T').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||||
|
)
|
||||||
|
const today = computed(() =>
|
||||||
|
new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Job lists ────────────────────────────────────────────────────────────────
|
||||||
|
const myJobs = computed(() => store.jobs)
|
||||||
|
const activeJob = computed(() => myJobs.value.find(j => j.status === 'in_progress') || null)
|
||||||
|
const upcomingJobs = computed(() =>
|
||||||
|
myJobs.value.filter(j => !j.completed && j.status !== 'in_progress' && j.status !== 'completed')
|
||||||
|
.sort((a, b) => (a.routeOrder || 99) - (b.routeOrder || 99))
|
||||||
|
)
|
||||||
|
const completedJobs = computed(() => myJobs.value.filter(j => j.status === 'completed'))
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ lbl: 'Total', val: myJobs.value.length },
|
||||||
|
{ lbl: 'A faire', val: upcomingJobs.value.length + (activeJob.value ? 1 : 0) },
|
||||||
|
{ lbl: 'Faits', val: completedJobs.value.length },
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Auth + boot ──────────────────────────────────────────────────────────────
|
||||||
|
async function loadTechs () {
|
||||||
|
const raw = await fetchTechnicians()
|
||||||
|
techList.value = raw.map((t, idx) => ({
|
||||||
|
name: t.name,
|
||||||
|
fullName: t.full_name || t.name,
|
||||||
|
techId: t.technician_id || t.name,
|
||||||
|
user: t.user || null,
|
||||||
|
colorIdx: idx,
|
||||||
|
}))
|
||||||
|
const linked = techList.value.find(t => t.user === auth.user)
|
||||||
|
selTechId.value = linked ? linked.name : (techList.value[0]?.name || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot () {
|
||||||
|
await auth.checkSession()
|
||||||
|
if (auth.user) {
|
||||||
|
loginUser.value = auth.user
|
||||||
|
await loadTechs()
|
||||||
|
phase.value = 'select-tech'
|
||||||
|
} else {
|
||||||
|
phase.value = 'login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin () {
|
||||||
|
await auth.doLogin(loginUser.value, loginPass.value)
|
||||||
|
if (auth.user) {
|
||||||
|
await loadTechs()
|
||||||
|
phase.value = 'select-tech'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout () {
|
||||||
|
await auth.doLogout()
|
||||||
|
store.jobs = []
|
||||||
|
selTechId.value = ''
|
||||||
|
phase.value = 'login'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJobs () {
|
||||||
|
if (!selTechId.value || !selTech.value) return
|
||||||
|
await store.loadJobsForTech(selTech.value.techId)
|
||||||
|
phase.value = 'jobs'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ──────────────────────────────────────────────────────────────────
|
||||||
|
async function markComplete (job) {
|
||||||
|
if (!job || job.status === 'completed') return
|
||||||
|
await store.setJobStatus(job.id, 'completed')
|
||||||
|
job.status = 'completed'
|
||||||
|
toast(job.id + ' complété !')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markEnRoute (job) {
|
||||||
|
if (!job) return
|
||||||
|
myJobs.value.forEach(j => { if (j.status === 'in_progress') j.status = 'assigned' })
|
||||||
|
await store.setJobStatus(job.id, 'in_progress')
|
||||||
|
job.status = 'in_progress'
|
||||||
|
detailJob.value = null
|
||||||
|
toast('En route vers ' + job.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast (msg) {
|
||||||
|
toastMsg.value = msg
|
||||||
|
showToast.value = true
|
||||||
|
setTimeout(() => { showToast.value = false }, 2800)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTime (idx) {
|
||||||
|
let m = 8 * 60
|
||||||
|
if (activeJob.value) {
|
||||||
|
m += (parseInt(activeJob.value.legDur) || 0) + (parseFloat(activeJob.value.duration) || 1) * 60
|
||||||
|
}
|
||||||
|
for (let i = 0; i < idx; i++) {
|
||||||
|
const j = upcomingJobs.value[i]
|
||||||
|
m += (parseInt(j.legDur) || 0) + (parseFloat(j.duration) || 1) * 60
|
||||||
|
}
|
||||||
|
m += parseInt(upcomingJobs.value[idx]?.legDur) || 0
|
||||||
|
return String(Math.floor(m / 60)).padStart(2, '0') + 'h' + String(m % 60).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function prioLbl (p) { return { high: 'Urgent', medium: 'Moyen', low: 'Faible' }[p] || p }
|
||||||
|
function prioStyle (p) {
|
||||||
|
return {
|
||||||
|
high: 'background:#fef2f2;color:#dc2626',
|
||||||
|
medium: 'background:#fffbeb;color:#d97706',
|
||||||
|
low: 'background:#f0fdf4;color:#16a34a',
|
||||||
|
}[p] || ''
|
||||||
|
}
|
||||||
|
function mapsUrl (addr) { return 'https://maps.google.com/?q=' + encodeURIComponent(addr) }
|
||||||
|
|
||||||
|
// ── Equipment / Barcode ───────────────────────────────────────────────────────
|
||||||
|
const EQUIPMENT_TYPES = ['Modem', 'Routeur', 'Décodeur TV', 'Téléphone IP', 'Câble coaxial', 'Amplificateur', 'Splitter', 'ONT/ONU', 'Autre']
|
||||||
|
|
||||||
|
const eqRequestName = ref('') // which service request we're working on
|
||||||
|
const eqItems = ref([]) // array of equipment items to submit
|
||||||
|
const eqSubmitting = ref(false)
|
||||||
|
const eqDone = ref(false)
|
||||||
|
const scannerActive = ref(false)
|
||||||
|
let _scanner = null
|
||||||
|
|
||||||
|
const eqJobs = computed(() =>
|
||||||
|
myJobs.value.filter(j => j.status !== 'completed')
|
||||||
|
)
|
||||||
|
|
||||||
|
function newEqItem (barcode = '') {
|
||||||
|
return { barcode, equipment_type: 'Modem', brand: '', model: '', notes: '', photo_base64: '', _id: Date.now() + Math.random() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEqItem () {
|
||||||
|
eqItems.value.push(newEqItem())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEqItem (item) {
|
||||||
|
eqItems.value = eqItems.value.filter(e => e._id !== item._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScanner () {
|
||||||
|
scannerActive.value = true
|
||||||
|
await nextTick()
|
||||||
|
try {
|
||||||
|
const { Html5Qrcode } = await import('html5-qrcode')
|
||||||
|
_scanner = new Html5Qrcode('qr-reader')
|
||||||
|
await _scanner.start(
|
||||||
|
{ facingMode: 'environment' },
|
||||||
|
{ fps: 10, qrbox: { width: 260, height: 80 } },
|
||||||
|
(decoded) => {
|
||||||
|
stopScanner()
|
||||||
|
const existing = eqItems.value.find(e => e.barcode === decoded)
|
||||||
|
if (!existing) {
|
||||||
|
eqItems.value.push(newEqItem(decoded))
|
||||||
|
toast('Scanné : ' + decoded)
|
||||||
|
} else {
|
||||||
|
toast('Déjà dans la liste')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
scannerActive.value = false
|
||||||
|
toast('Caméra non disponible')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopScanner () {
|
||||||
|
if (_scanner) {
|
||||||
|
await _scanner.stop().catch(() => {})
|
||||||
|
_scanner = null
|
||||||
|
}
|
||||||
|
scannerActive.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPhotoChange (item, event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = e => { item.photo_base64 = e.target.result }
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEquipment () {
|
||||||
|
if (!eqRequestName.value || eqItems.value.length === 0) return
|
||||||
|
eqSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const item of eqItems.value) {
|
||||||
|
await createEquipmentInstall({
|
||||||
|
request: eqRequestName.value,
|
||||||
|
barcode: item.barcode,
|
||||||
|
equipment_type: item.equipment_type,
|
||||||
|
brand: item.brand,
|
||||||
|
model: item.model,
|
||||||
|
notes: item.notes,
|
||||||
|
photo_base64: item.photo_base64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const count = eqItems.value.length
|
||||||
|
eqItems.value = []
|
||||||
|
eqDone.value = true
|
||||||
|
toast(count + ' équipement(s) enregistré(s)')
|
||||||
|
} catch {
|
||||||
|
toast('Erreur lors de la soumission')
|
||||||
|
} finally {
|
||||||
|
eqSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => { stopScanner() })
|
||||||
|
|
||||||
|
onMounted(boot)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mobile-app">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="app-header">
|
||||||
|
<div class="app-header-bar">
|
||||||
|
<div>
|
||||||
|
<div class="app-header-sub">{{ today }}</div>
|
||||||
|
<div class="app-header-title">
|
||||||
|
<span v-if="phase === 'jobs'">{{ techName }}</span>
|
||||||
|
<span v-else-if="phase === 'select-tech'">Choisir un technicien</span>
|
||||||
|
<span v-else>Dispatch Mobile</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<span :class="auth.user ? 'badge badge-online' : 'badge badge-offline'">
|
||||||
|
{{ auth.user ? 'En ligne' : 'Hors ligne' }}
|
||||||
|
</span>
|
||||||
|
<button v-if="phase === 'jobs'" class="btn-icon"
|
||||||
|
@click="phase = 'select-tech'" title="Changer de tech">⇆</button>
|
||||||
|
<div v-if="phase === 'jobs'" class="avatar" :style="'background:' + techColor">
|
||||||
|
{{ initials }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="phase === 'jobs'" class="stats-strip">
|
||||||
|
<div v-for="s in stats" :key="s.lbl" class="stat-box">
|
||||||
|
<div class="stat-val">{{ s.val }}</div>
|
||||||
|
<div class="stat-lbl">{{ s.lbl }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="app-content">
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="phase === 'loading'" style="display:flex;flex-direction:column;align-items:center;padding:3rem 1rem;gap:1rem;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div style="color:#94a3b8;font-size:0.88rem;">Chargement...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login -->
|
||||||
|
<div v-else-if="phase === 'login'" class="login-wrap">
|
||||||
|
<div class="login-hero">
|
||||||
|
<div class="login-icon">⚡</div>
|
||||||
|
<div style="font-size:1.2rem;font-weight:700;color:#1e293b;">Connexion ERPNext</div>
|
||||||
|
<div style="color:#64748b;font-size:0.82rem;margin-top:0.3rem;">Entrez vos identifiants pour continuer</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-card">
|
||||||
|
<label class="field-label">Utilisateur (email)</label>
|
||||||
|
<input v-model="loginUser" type="email" placeholder="admin@example.com"
|
||||||
|
class="field-input" :class="{ err: !!auth.error }" @keyup.enter="doLogin" />
|
||||||
|
<label class="field-label">Mot de passe</label>
|
||||||
|
<input v-model="loginPass" :type="showPwd ? 'text' : 'password'" placeholder="••••••••"
|
||||||
|
class="field-input" :class="{ err: !!auth.error }" @keyup.enter="doLogin" />
|
||||||
|
<div v-if="auth.error" class="error-msg">{{ auth.error }}</div>
|
||||||
|
<label class="show-pwd">
|
||||||
|
<input type="checkbox" v-model="showPwd" /> Afficher le mot de passe
|
||||||
|
</label>
|
||||||
|
<button class="btn-primary"
|
||||||
|
:disabled="!loginUser || !loginPass || auth.loading"
|
||||||
|
@click="doLogin">
|
||||||
|
{{ auth.loading ? 'Connexion...' : 'Se connecter' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Select tech -->
|
||||||
|
<div v-else-if="phase === 'select-tech'" class="login-wrap">
|
||||||
|
<div class="login-hero">
|
||||||
|
<div class="login-icon" style="font-size:1.6rem;">👷</div>
|
||||||
|
<div style="font-size:1.2rem;font-weight:700;color:#1e293b;">Choisir un technicien</div>
|
||||||
|
<div style="color:#64748b;font-size:0.82rem;margin-top:0.3rem;">
|
||||||
|
Connecté : <strong>{{ auth.user }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-card">
|
||||||
|
<label class="field-label">Technicien</label>
|
||||||
|
<select v-model="selTechId" class="field-select">
|
||||||
|
<option value="" disabled>-- Choisir --</option>
|
||||||
|
<option v-for="t in techList" :key="t.name" :value="t.name">{{ t.fullName }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-primary" :disabled="!selTechId || store.loading" @click="loadJobs">
|
||||||
|
{{ store.loading ? 'Chargement...' : 'Voir les jobs →' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" @click="doLogout">Changer de compte</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jobs -->
|
||||||
|
<template v-else-if="phase === 'jobs'">
|
||||||
|
|
||||||
|
<!-- En cours -->
|
||||||
|
<div v-if="activeJob">
|
||||||
|
<div class="section-label">En cours</div>
|
||||||
|
<div class="job-card active-card" :style="'border-left-color:' + techColor"
|
||||||
|
@click="detailJob = activeJob">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<span class="prio-dot" :class="'prio-' + activeJob.priority"></span>
|
||||||
|
<span style="font-size:0.72rem;font-weight:700;color:#6366f1;">{{ activeJob.id }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-active">En cours</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.97rem;font-weight:700;margin-bottom:0.3rem;">{{ activeJob.subject }}</div>
|
||||||
|
<div style="font-size:0.77rem;color:#64748b;margin-bottom:0.6rem;">📍 {{ activeJob.address }}</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<span class="chip">⏲ {{ activeJob.duration }}h</span>
|
||||||
|
<span v-if="activeJob.legDur" class="chip">🚘 {{ activeJob.legDur }}min</span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<button class="btn-green" style="flex:0;padding:4px 14px;font-size:0.75rem;border-radius:8px;"
|
||||||
|
@click.stop="markComplete(activeJob)">Terminer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- A venir -->
|
||||||
|
<div class="section-label">A venir ({{ upcomingJobs.length }})</div>
|
||||||
|
<div v-if="upcomingJobs.length === 0"
|
||||||
|
style="text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.85rem;">
|
||||||
|
Aucun job à venir
|
||||||
|
</div>
|
||||||
|
<div v-for="(job, idx) in upcomingJobs" :key="job.id"
|
||||||
|
class="job-card" :style="'border-left-color:' + techColor" @click="detailJob = job">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.4rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<div class="num-bubble" :style="'background:' + techColor">
|
||||||
|
{{ idx + (activeJob ? 2 : 1) }}
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.72rem;font-weight:600;color:#6366f1;">{{ job.id }}</span>
|
||||||
|
<span class="prio-dot" :class="'prio-' + job.priority"></span>
|
||||||
|
</div>
|
||||||
|
<span class="chip">{{ startTime(idx) }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.92rem;font-weight:600;margin-bottom:0.25rem;">{{ job.subject }}</div>
|
||||||
|
<div style="font-size:0.75rem;color:#64748b;margin-bottom:0.5rem;">📍 {{ job.address }}</div>
|
||||||
|
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;">
|
||||||
|
<span class="chip">⏲ {{ job.duration }}h</span>
|
||||||
|
<span v-if="job.legDur" class="chip">🚘 {{ job.legDur }}m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complétés -->
|
||||||
|
<div v-if="completedJobs.length > 0">
|
||||||
|
<div class="section-label" @click="showCompleted = !showCompleted">
|
||||||
|
Complétés ({{ completedJobs.length }})
|
||||||
|
<span style="font-size:0.9rem;">{{ showCompleted ? '⌃' : '⌄' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCompleted">
|
||||||
|
<div v-for="job in completedJobs" :key="job.id"
|
||||||
|
class="job-card done-card" @click="detailJob = job">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.6rem;">
|
||||||
|
<div class="check-circle">✓</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.85rem;font-weight:600;text-decoration:line-through;color:#64748b;">
|
||||||
|
{{ job.subject }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.71rem;color:#94a3b8;">{{ job.id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aucun job -->
|
||||||
|
<div v-if="myJobs.length === 0" style="text-align:center;padding:3rem 1rem;">
|
||||||
|
<div style="font-size:3rem;margin-bottom:0.75rem;">📅</div>
|
||||||
|
<div style="font-size:1rem;font-weight:600;color:#374151;margin-bottom:0.3rem;">Aucun job aujourd'hui</div>
|
||||||
|
<div style="color:#94a3b8;font-size:0.83rem;">Votre planning est vide.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Equipment tab ──────────────────────────────────────────────────── -->
|
||||||
|
<template v-else-if="phase === 'jobs' && tab === 'equipment'">
|
||||||
|
|
||||||
|
<!-- Confirm done banner -->
|
||||||
|
<div v-if="eqDone" class="eq-done-banner">
|
||||||
|
✓ Équipements enregistrés avec succès !
|
||||||
|
<button @click="eqDone = false" style="margin-left:0.75rem;background:none;border:none;color:inherit;font-size:1rem;cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request picker -->
|
||||||
|
<div class="section-label">Appel de service</div>
|
||||||
|
<select v-model="eqRequestName" class="field-select" style="margin-bottom:0.5rem;">
|
||||||
|
<option value="" disabled>-- Choisir un ticket --</option>
|
||||||
|
<option v-for="j in eqJobs" :key="j.id" :value="j.id">{{ j.id }} — {{ j.subject }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Scanner -->
|
||||||
|
<div class="section-label">Scanner un code-barres</div>
|
||||||
|
<div v-if="!scannerActive" style="margin-bottom:1rem;">
|
||||||
|
<button class="btn-indigo-full" @click="startScanner">
|
||||||
|
📷 Activer la caméra
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else style="margin-bottom:1rem;">
|
||||||
|
<div id="qr-reader" class="qr-reader-box"></div>
|
||||||
|
<button class="btn-secondary" style="width:100%;margin-top:0.5rem;" @click="stopScanner">
|
||||||
|
Arrêter le scanner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scanned items -->
|
||||||
|
<div class="section-label">Équipements ({{ eqItems.length }})</div>
|
||||||
|
<div v-if="eqItems.length === 0" style="text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.85rem;">
|
||||||
|
Scannez un code-barres ou ajoutez manuellement.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="item in eqItems" :key="item._id" class="eq-card">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
|
||||||
|
<span style="font-size:0.72rem;font-weight:700;color:#6366f1;flex:1;">CODE-BARRES</span>
|
||||||
|
<button @click="removeEqItem(item)" style="background:none;border:none;color:#ef4444;font-size:1.1rem;cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<input v-model="item.barcode" placeholder="Code-barres ou numéro de série" class="eq-input" />
|
||||||
|
|
||||||
|
<label class="eq-label">Type d'équipement</label>
|
||||||
|
<select v-model="item.equipment_type" class="eq-select">
|
||||||
|
<option v-for="t in EQUIPMENT_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
|
||||||
|
<div>
|
||||||
|
<label class="eq-label">Marque</label>
|
||||||
|
<input v-model="item.brand" placeholder="ex: Cisco" class="eq-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="eq-label">Modèle</label>
|
||||||
|
<input v-model="item.model" placeholder="ex: DPC3829" class="eq-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="eq-label">Notes</label>
|
||||||
|
<input v-model="item.notes" placeholder="Observations, port, emplacement..." class="eq-input" />
|
||||||
|
|
||||||
|
<label class="eq-label">Photo</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
|
<label class="btn-photo">
|
||||||
|
📷 Prendre une photo
|
||||||
|
<input type="file" accept="image/*" capture="environment" style="display:none"
|
||||||
|
@change="onPhotoChange(item, $event)" />
|
||||||
|
</label>
|
||||||
|
<img v-if="item.photo_base64" :src="item.photo_base64" class="eq-thumb" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-secondary" style="width:100%;margin-top:0.5rem;margin-bottom:0.75rem;" @click="addEqItem">
|
||||||
|
+ Ajouter manuellement
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-green-full"
|
||||||
|
:disabled="!eqRequestName || eqItems.length === 0 || eqSubmitting"
|
||||||
|
@click="submitEquipment">
|
||||||
|
{{ eqSubmitting ? 'Enregistrement...' : 'Enregistrer les équipements (' + eqItems.length + ')' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer tabs -->
|
||||||
|
<div class="app-footer">
|
||||||
|
<button class="tab-btn" :class="{ active: tab === 'jobs' }" @click="tab = 'jobs'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
|
</svg>
|
||||||
|
Jobs
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" :class="{ active: tab === 'map' }" @click="tab = 'map'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>
|
||||||
|
</svg>
|
||||||
|
Carte
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" :class="{ active: tab === 'equipment' }"
|
||||||
|
@click="tab = 'equipment'; if(scannerActive) stopScanner()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 7h.01M12 7h.01M17 7h.01M7 12h.01M12 12h.01M17 12h.01M7 17h.01M12 17h.01M17 17h.01"/>
|
||||||
|
</svg>
|
||||||
|
Équip.
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" :class="{ active: tab === 'profile' }" @click="tab = 'profile'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
Profil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail modal -->
|
||||||
|
<div v-if="detailJob" class="modal-backdrop" @click.self="detailJob = null">
|
||||||
|
<div class="modal-sheet">
|
||||||
|
<div class="modal-handle"><div class="modal-handle-bar"></div></div>
|
||||||
|
<div style="padding:1rem 1.25rem 0.25rem;display:flex;align-items:flex-start;justify-content:space-between;gap:0.5rem;">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.35rem;">
|
||||||
|
<span style="font-size:0.75rem;font-weight:700;color:#6366f1;">{{ detailJob.id }}</span>
|
||||||
|
<span class="prio-dot" :class="'prio-' + detailJob.priority"></span>
|
||||||
|
<span style="font-size:0.68rem;font-weight:600;padding:2px 8px;border-radius:6px;"
|
||||||
|
:style="prioStyle(detailJob.priority)">{{ prioLbl(detailJob.priority) }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:1.05rem;font-weight:700;color:#1e293b;">{{ detailJob.subject }}</div>
|
||||||
|
</div>
|
||||||
|
<button @click="detailJob = null"
|
||||||
|
style="background:none;border:none;font-size:1.4rem;color:#94a3b8;cursor:pointer;line-height:1;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-row-icon">📍</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="modal-row-label">Adresse</div>
|
||||||
|
<div class="modal-row-value">{{ detailJob.address }}</div>
|
||||||
|
</div>
|
||||||
|
<a :href="mapsUrl(detailJob.address)" target="_blank"
|
||||||
|
style="color:#6366f1;font-size:0.8rem;text-decoration:none;font-weight:600;">Carte</a>
|
||||||
|
</div>
|
||||||
|
<div class="modal-row">
|
||||||
|
<div class="modal-row-icon">⏲</div>
|
||||||
|
<div>
|
||||||
|
<div class="modal-row-label">Durée estimée</div>
|
||||||
|
<div class="modal-row-value">{{ detailJob.duration }}h</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="detailJob.legDist" class="modal-row">
|
||||||
|
<div class="modal-row-icon">🚘</div>
|
||||||
|
<div>
|
||||||
|
<div class="modal-row-label">Trajet jusqu'au job</div>
|
||||||
|
<div class="modal-row-value">{{ detailJob.legDist }} km · {{ detailJob.legDur }} min</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button v-if="detailJob.status !== 'completed'" class="btn-indigo"
|
||||||
|
@click="markEnRoute(detailJob)">En route</button>
|
||||||
|
<button v-if="detailJob.status !== 'completed'" class="btn-green"
|
||||||
|
@click="markComplete(detailJob); detailJob = null">Terminer</button>
|
||||||
|
<button v-if="detailJob.status === 'completed'" disabled
|
||||||
|
style="flex:1;padding:0.7rem;background:#f1f5f9;color:#94a3b8;border:none;border-radius:10px;font-weight:700;font-family:inherit;">
|
||||||
|
Ticket complété
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div v-if="showToast" class="toast">✓ {{ toastMsg }}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
.mobile-app { height: 100vh; display: flex; flex-direction: column; background: #f1f5f9; color: #1e293b; font-family: 'Inter', system-ui, sans-serif; }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.app-header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; flex-shrink: 0; }
|
||||||
|
.app-header-bar { display: flex; align-items: center; justify-content: space-between; padding: 0.9rem 1rem; }
|
||||||
|
.app-header-title { font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.app-header-sub { font-size: 0.7rem; opacity: 0.75; margin-bottom: 2px; }
|
||||||
|
.app-content { flex: 1; overflow-y: auto; padding: 1rem; padding-bottom: 5rem; }
|
||||||
|
.app-footer { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-top: 1px solid #e2e8f0; display: flex; z-index: 100; }
|
||||||
|
.tab-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0.55rem 0; font-size: 0.65rem; font-weight: 600; color: #94a3b8; border: none; background: none; cursor: pointer; transition: color 0.15s; }
|
||||||
|
.tab-btn.active { color: #6366f1; }
|
||||||
|
.tab-btn svg { width: 20px; height: 20px; }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-strip { background: #4f46e5; display: flex; padding: 0.5rem 1rem; gap: 0.5rem; }
|
||||||
|
.stat-box { flex: 1; background: rgba(255,255,255,0.12); border-radius: 8px; padding: 0.4rem 0.5rem; text-align: center; }
|
||||||
|
.stat-val { font-size: 1.1rem; font-weight: 700; color: white; }
|
||||||
|
.stat-lbl { font-size: 0.6rem; color: rgba(255,255,255,0.75); margin-top: 1px; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge { display: inline-flex; align-items: center; font-size: 0.65rem; font-weight: 700; padding: 2px 8px; border-radius: 20px; }
|
||||||
|
.badge-online { background: rgba(74,222,128,0.25); color: #16a34a; }
|
||||||
|
.badge-offline { background: rgba(248,113,113,0.25); color: #dc2626; }
|
||||||
|
.badge-active { background: #e0e7ff; color: #4338ca; }
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-wrap { max-width: 400px; margin: 0 auto; padding-top: 0.5rem; }
|
||||||
|
.login-hero { text-align: center; padding: 1.75rem 0 1.25rem; }
|
||||||
|
.login-icon { width: 68px; height: 68px; border-radius: 20px; background: linear-gradient(135deg,#6366f1,#8b5cf6); display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 2rem; }
|
||||||
|
.login-card { background: white; border-radius: 16px; padding: 1.5rem; box-shadow: 0 4px 24px rgba(0,0,0,0.07); }
|
||||||
|
.field-label { font-size: 0.75rem; font-weight: 600; color: #475569; display: block; margin-bottom: 0.35rem; }
|
||||||
|
.field-input { width: 100%; padding: 0.7rem 0.9rem; font-size: 0.9rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 10px; outline: none; transition: border-color 0.15s; margin-bottom: 0.85rem; }
|
||||||
|
.field-input:focus { border-color: #6366f1; }
|
||||||
|
.field-input.err { border-color: #ef4444; }
|
||||||
|
.show-pwd { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 1rem; font-size: 0.78rem; color: #64748b; cursor: pointer; }
|
||||||
|
.show-pwd input { accent-color: #6366f1; }
|
||||||
|
.error-msg { font-size: 0.78rem; color: #ef4444; margin: -0.6rem 0 0.75rem; padding-left: 0.1rem; }
|
||||||
|
.field-select { width: 100%; padding: 0.7rem 0.9rem; font-size: 0.9rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 10px; outline: none; background: white; margin-bottom: 1rem; cursor: pointer; }
|
||||||
|
.field-select:focus { border-color: #6366f1; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary { width: 100%; padding: 0.75rem; font-size: 0.92rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; transition: background 0.15s; }
|
||||||
|
.btn-primary:hover { background: #4f46e5; }
|
||||||
|
.btn-primary:disabled { background: #c7d2fe; cursor: not-allowed; }
|
||||||
|
.btn-secondary { width: 100%; padding: 0.65rem; font-size: 0.85rem; font-weight: 600; font-family: inherit; background: transparent; color: #64748b; border: 1.5px solid #e2e8f0; border-radius: 10px; cursor: pointer; margin-top: 0.6rem; transition: all 0.15s; }
|
||||||
|
.btn-secondary:hover { border-color: #6366f1; color: #6366f1; }
|
||||||
|
.btn-green { flex: 1; padding: 0.7rem; font-size: 0.88rem; font-weight: 700; font-family: inherit; background: #22c55e; color: white; border: none; border-radius: 10px; cursor: pointer; }
|
||||||
|
.btn-indigo { flex: 1; padding: 0.7rem; font-size: 0.88rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; }
|
||||||
|
.btn-icon { background: rgba(255,255,255,0.2); border: none; color: white; width: 34px; height: 34px; border-radius: 50%; cursor: pointer; font-size: 1.1rem; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.section-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; margin: 1.25rem 0 0.5rem; padding: 0 0.1rem; display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
|
||||||
|
.job-card { background: white; border-radius: 14px; padding: 0.9rem 1rem; margin-bottom: 0.7rem; box-shadow: 0 2px 8px rgba(0,0,0,0.06); border-left: 4px solid #e2e8f0; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; }
|
||||||
|
.job-card:active { transform: scale(0.985); }
|
||||||
|
.active-card { box-shadow: 0 4px 20px rgba(99,102,241,0.18); }
|
||||||
|
.done-card { opacity: 0.6; border-left-color: #22c55e !important; background: #f9fafb; }
|
||||||
|
.prio-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
|
.prio-high { background: #ef4444; }
|
||||||
|
.prio-medium { background: #f59e0b; }
|
||||||
|
.prio-low { background: #10b981; }
|
||||||
|
.chip { background: #f1f5f9; border-radius: 6px; padding: 2px 8px; font-size: 0.7rem; font-weight: 600; color: #475569; }
|
||||||
|
.num-bubble { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; color: white; flex-shrink: 0; }
|
||||||
|
.avatar { width: 36px; height: 36px; border-radius: 50%; font-weight: 700; font-size: 0.85rem; color: white; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.check-circle { width: 32px; height: 32px; border-radius: 50%; background: #22c55e; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: white; font-size: 1rem; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 200; display: flex; align-items: flex-end; }
|
||||||
|
.modal-sheet { background: white; border-radius: 18px 18px 0 0; width: 100%; max-width: 600px; margin: 0 auto; padding-bottom: env(safe-area-inset-bottom, 16px); animation: slideUp 0.22s ease; }
|
||||||
|
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
.modal-handle { display: flex; justify-content: center; padding: 0.75rem 0 0; }
|
||||||
|
.modal-handle-bar { width: 40px; height: 4px; border-radius: 2px; background: #e2e8f0; }
|
||||||
|
.modal-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 1.25rem; }
|
||||||
|
.modal-row-icon { color: #94a3b8; font-size: 1.1rem; width: 22px; text-align: center; }
|
||||||
|
.modal-row-label { font-size: 0.68rem; color: #94a3b8; }
|
||||||
|
.modal-row-value { font-size: 0.88rem; font-weight: 600; color: #1e293b; }
|
||||||
|
.modal-actions { display: flex; gap: 0.5rem; padding: 0.75rem 1.25rem 1.25rem; }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast { position: fixed; top: 1rem; left: 50%; transform: translateX(-50%); background: #22c55e; color: white; border-radius: 12px; padding: 0.65rem 1.2rem; font-weight: 700; font-size: 0.88rem; z-index: 300; white-space: nowrap; box-shadow: 0 4px 16px rgba(0,0,0,0.15); animation: fadeIn 0.2s ease; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateX(-50%) translateY(-8px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spinner { width: 40px; height: 40px; border: 3px solid #e2e8f0; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Equipment tab */
|
||||||
|
.eq-done-banner { background: #dcfce7; color: #16a34a; border-radius: 10px; padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600; margin-bottom: 1rem; display: flex; align-items: center; }
|
||||||
|
.eq-card { background: white; border-radius: 14px; padding: 1rem; margin-bottom: 0.75rem; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||||
|
.eq-label { display: block; font-size: 0.68rem; font-weight: 600; color: #64748b; margin: 0.55rem 0 0.2rem; }
|
||||||
|
.eq-input { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 8px; outline: none; margin-bottom: 0.1rem; }
|
||||||
|
.eq-input:focus { border-color: #6366f1; }
|
||||||
|
.eq-select { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 8px; outline: none; background: white; }
|
||||||
|
.eq-select:focus { border-color: #6366f1; }
|
||||||
|
.qr-reader-box { width: 100%; border-radius: 12px; overflow: hidden; border: 2px solid #6366f1; background: #000; min-height: 200px; }
|
||||||
|
.btn-photo { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.9rem; font-size: 0.78rem; font-weight: 600; font-family: inherit; background: #ede9fe; color: #6366f1; border: none; border-radius: 8px; cursor: pointer; }
|
||||||
|
.eq-thumb { width: 52px; height: 52px; object-fit: cover; border-radius: 8px; border: 2px solid #e2e8f0; }
|
||||||
|
.btn-indigo-full { width: 100%; padding: 0.72rem; font-size: 0.9rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; }
|
||||||
|
.btn-green-full { width: 100%; padding: 0.72rem; font-size: 0.9rem; font-weight: 700; font-family: inherit; background: #22c55e; color: white; border: none; border-radius: 10px; cursor: pointer; }
|
||||||
|
.btn-green-full:disabled { background: #86efac; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
399
apps/dispatch/src/pages/TechBidPage.vue
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* TechBidPage — Vue Uber pour techniciens
|
||||||
|
* Affiche les demandes disponibles, permet d'accepter une date et soumettre un bid.
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { fetchOpenRequests, createServiceBid } from 'src/api/service-request'
|
||||||
|
|
||||||
|
// ── Auth locale (simple, sans store) ─────────────────────────────────────────
|
||||||
|
const techName = ref(localStorage.getItem('dispatch-tech-name') || '')
|
||||||
|
const techId = ref(localStorage.getItem('dispatch-tech-id') || '')
|
||||||
|
const showLogin = ref(!techId.value)
|
||||||
|
|
||||||
|
const loginName = ref('')
|
||||||
|
async function loginAsTech () {
|
||||||
|
if (!loginName.value.trim()) return
|
||||||
|
techName.value = loginName.value.trim()
|
||||||
|
techId.value = loginName.value.trim().toLowerCase().replace(/\s+/g, '-')
|
||||||
|
localStorage.setItem('dispatch-tech-name', techName.value)
|
||||||
|
localStorage.setItem('dispatch-tech-id', techId.value)
|
||||||
|
showLogin.value = false
|
||||||
|
loadRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Demandes disponibles ──────────────────────────────────────────────────────
|
||||||
|
const requests = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const expandedId = ref(null)
|
||||||
|
|
||||||
|
async function loadRequests () {
|
||||||
|
loading.value = true
|
||||||
|
try { requests.value = await fetchOpenRequests() }
|
||||||
|
catch (_) { requests.value = [] }
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { if (!showLogin.value) loadRequests() })
|
||||||
|
|
||||||
|
// ── Bid state ─────────────────────────────────────────────────────────────────
|
||||||
|
const bidState = ref({}) // { [requestName]: { date, timeSlot, duration, notes } }
|
||||||
|
const bidding = ref({}) // { [requestName]: true } = en cours
|
||||||
|
const bidSent = ref({}) // { [requestName]: true } = confirmé
|
||||||
|
|
||||||
|
function getBid (name) {
|
||||||
|
if (!bidState.value[name]) bidState.value[name] = { date: '', timeSlot: '', duration: '2', notes: '', price: '' }
|
||||||
|
return bidState.value[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_ICONS = { internet: '🌐', tv: '📺', telephone: '📞', multi: '🔧' }
|
||||||
|
const SERVICE_LABELS = { internet: 'Internet', tv: 'Télévision', telephone: 'Téléphonie', multi: 'Multiple' }
|
||||||
|
const URGENCY_COLORS = { urgent: '#f43f5e', normal: '#6366f1' }
|
||||||
|
|
||||||
|
const TIME_SLOTS = [
|
||||||
|
{ id: 'morning', label: 'Matin', sub: '8h–12h' },
|
||||||
|
{ id: 'afternoon', label: 'Après-midi', sub: '12h–17h' },
|
||||||
|
{ id: 'evening', label: 'Soir', sub: '17h–20h' },
|
||||||
|
{ id: 'flexible', label: 'Flexible', sub: 'Au choix' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Dates proposées par le client pour cette demande
|
||||||
|
// Supporte 2 formats : champs plats Frappe (preferred_date_1…) et tableau localStorage (preferred_dates[])
|
||||||
|
function getClientDates (req) {
|
||||||
|
// Format tableau localStorage
|
||||||
|
if (Array.isArray(req.preferred_dates) && req.preferred_dates.length > 0) {
|
||||||
|
return req.preferred_dates
|
||||||
|
.filter(d => d.date)
|
||||||
|
.map((d, i) => ({
|
||||||
|
date: d.date,
|
||||||
|
slot: d.time_slot || (Array.isArray(d.time_slots) ? d.time_slots[0] : '') || '',
|
||||||
|
slots: Array.isArray(d.time_slots) ? d.time_slots : (d.time_slot ? [d.time_slot] : []),
|
||||||
|
priority: i + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
// Format champs plats Frappe
|
||||||
|
const dates = []
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const d = req[`preferred_date_${i}`]
|
||||||
|
const s = req[`time_slot_${i}`]
|
||||||
|
if (d) dates.push({ date: d, slot: s, slots: s ? [s] : [], priority: i })
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate (iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso + 'T12:00:00')
|
||||||
|
return d.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo (iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const m = Math.floor(diff / 60000)
|
||||||
|
if (m < 60) return `il y a ${m}min`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h < 24) return `il y a ${h}h`
|
||||||
|
return `il y a ${Math.floor(h / 24)}j`
|
||||||
|
}
|
||||||
|
|
||||||
|
const canBid = (name) => {
|
||||||
|
const b = bidState.value[name]
|
||||||
|
return b?.date && b?.timeSlot && b?.price
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBid (req) {
|
||||||
|
const b = getBid(req.name)
|
||||||
|
if (!canBid(req.name)) return
|
||||||
|
bidding.value = { ...bidding.value, [req.name]: true }
|
||||||
|
try {
|
||||||
|
await createServiceBid({
|
||||||
|
request: req.name,
|
||||||
|
technician: techId.value,
|
||||||
|
proposed_date: b.date,
|
||||||
|
time_slot: b.timeSlot,
|
||||||
|
estimated_duration: b.duration,
|
||||||
|
notes: b.notes,
|
||||||
|
price: b.price,
|
||||||
|
})
|
||||||
|
bidSent.value = { ...bidSent.value, [req.name]: true }
|
||||||
|
expandedId.value = null
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
bidding.value = { ...bidding.value, [req.name]: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decline (name) {
|
||||||
|
// Simply hide from list locally (no API call needed — tech just ignores)
|
||||||
|
requests.value = requests.value.filter(r => r.name !== name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRequests = computed(() => requests.value.filter(r => !bidSent.value[r.name]))
|
||||||
|
const sentCount = computed(() => Object.keys(bidSent.value).length)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bid-root">
|
||||||
|
|
||||||
|
<!-- Login ────────────────────────────────────────────────────────────────── -->
|
||||||
|
<div v-if="showLogin" class="login-screen">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-icon">👷</div>
|
||||||
|
<h2>Portail Technicien</h2>
|
||||||
|
<p>Entrez votre nom pour voir les mandats disponibles.</p>
|
||||||
|
<input v-model="loginName" type="text" placeholder="Votre nom" class="login-input"
|
||||||
|
@keyup.enter="loginAsTech" />
|
||||||
|
<button class="btn-login" @click="loginAsTech" :disabled="!loginName.trim()">
|
||||||
|
Accéder aux mandats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main ─────────────────────────────────────────────────────────────────── -->
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bid-header">
|
||||||
|
<div class="bid-header-left">
|
||||||
|
<div class="tech-avatar">{{ techName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
|
||||||
|
<div>
|
||||||
|
<div class="tech-name">{{ techName }}</div>
|
||||||
|
<div class="tech-sub">Technicien</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span v-if="sentCount" class="badge-sent">{{ sentCount }} envoyé{{ sentCount > 1 ? 's' : '' }}</span>
|
||||||
|
<button class="btn-refresh" @click="loadRequests" :disabled="loading" title="Actualiser">
|
||||||
|
<span :class="{ spinning: loading }">⟳</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty / loading -->
|
||||||
|
<div v-if="loading" class="state-center">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Chargement des mandats…</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="pendingRequests.length === 0" class="state-center">
|
||||||
|
<div class="empty-icon">📭</div>
|
||||||
|
<p>Aucun mandat disponible pour le moment.</p>
|
||||||
|
<button class="btn-ghost" @click="loadRequests">Actualiser</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request cards -->
|
||||||
|
<div v-else class="requests-list">
|
||||||
|
<div v-for="req in pendingRequests" :key="req.name" class="req-card"
|
||||||
|
:class="{ expanded: expandedId === req.name, urgent: req.urgency === 'urgent' }">
|
||||||
|
|
||||||
|
<!-- Card header -->
|
||||||
|
<div class="req-card-header" @click="expandedId = expandedId === req.name ? null : req.name">
|
||||||
|
<div class="req-type-badge" :style="{ background: 'rgba(99,102,241,0.15)', borderColor: 'rgba(99,102,241,0.3)' }">
|
||||||
|
{{ SERVICE_ICONS[req.service_type] || '🔧' }} {{ SERVICE_LABELS[req.service_type] || req.service_type }}
|
||||||
|
</div>
|
||||||
|
<div v-if="req.urgency === 'urgent'" class="urgent-badge">🚨 Urgent</div>
|
||||||
|
|
||||||
|
<div class="req-problem">{{ req.problem_type }}</div>
|
||||||
|
<div class="req-addr">📍 {{ req.address }}</div>
|
||||||
|
|
||||||
|
<!-- Client date preferences preview -->
|
||||||
|
<div class="req-dates-preview">
|
||||||
|
<span v-for="pd in getClientDates(req)" :key="pd.priority"
|
||||||
|
class="date-chip" :class="{ 'date-chip-1': pd.priority === 1 }">
|
||||||
|
{{ formatDate(pd.date) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="req-meta">
|
||||||
|
<span>{{ timeAgo(req.creation) }}</span>
|
||||||
|
<span v-if="req.budget_label" class="budget-pill">💰 {{ req.budget_label }}</span>
|
||||||
|
<span>{{ expandedId === req.name ? '▲ Masquer' : '▼ Voir détails' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded bid form -->
|
||||||
|
<div v-if="expandedId === req.name" class="req-bid-form">
|
||||||
|
<div class="bid-section-title">Description du client</div>
|
||||||
|
<p class="bid-description">{{ req.description || 'Aucune description fournie.' }}</p>
|
||||||
|
|
||||||
|
<div class="bid-section-title">Dates proposées par le client</div>
|
||||||
|
<div class="client-dates">
|
||||||
|
<button v-for="pd in getClientDates(req)" :key="pd.priority"
|
||||||
|
class="client-date-btn"
|
||||||
|
:class="{ selected: getBid(req.name).date === pd.date && getBid(req.name).timeSlot === pd.slot }"
|
||||||
|
@click="getBid(req.name).date = pd.date; getBid(req.name).timeSlot = pd.slot">
|
||||||
|
<div class="cd-priority">{{ pd.priority }}e choix</div>
|
||||||
|
<div class="cd-date">{{ formatDate(pd.date) }}</div>
|
||||||
|
<div class="cd-slot">{{ (pd.slots.length > 0 ? pd.slots : [pd.slot]).map(s => TIME_SLOTS.find(t => t.id === s)?.label || s).filter(Boolean).join(' · ') || 'Flexible' }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bid-section-title">Ou proposer une autre date</div>
|
||||||
|
<div class="alt-date-row">
|
||||||
|
<input type="date" class="date-input" v-model="getBid(req.name).date"
|
||||||
|
:min="new Date().toISOString().split('T')[0]" />
|
||||||
|
<select class="slot-select" v-model="getBid(req.name).timeSlot">
|
||||||
|
<option value="">Plage horaire…</option>
|
||||||
|
<option v-for="s in TIME_SLOTS" :key="s.id" :value="s.id">{{ s.label }} ({{ s.sub }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bid-section-title">Durée estimée</div>
|
||||||
|
<div class="duration-row">
|
||||||
|
<button v-for="h in ['1','2','3','4','6']" :key="h"
|
||||||
|
class="dur-btn"
|
||||||
|
:class="{ selected: getBid(req.name).duration === h }"
|
||||||
|
@click="getBid(req.name).duration = h">
|
||||||
|
{{ h }}h
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bid-section-title">Mon tarif <span class="required-star">*</span></div>
|
||||||
|
<div class="price-row">
|
||||||
|
<div class="price-input-wrap">
|
||||||
|
<span class="price-currency">$</span>
|
||||||
|
<input type="number" class="price-input" v-model="getBid(req.name).price"
|
||||||
|
placeholder="0" min="0" step="5"
|
||||||
|
@click.stop />
|
||||||
|
<span class="price-unit">/ projet</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="req.budget_label" class="price-hint">
|
||||||
|
Budget client : <strong>{{ req.budget_label }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea class="notes-input" v-model="getBid(req.name).notes"
|
||||||
|
placeholder="Note pour le dispatcher (optionnel)…" rows="2"></textarea>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="bid-actions">
|
||||||
|
<button class="btn-decline" @click="decline(req.name)">
|
||||||
|
✕ Décliner
|
||||||
|
</button>
|
||||||
|
<button class="btn-accept"
|
||||||
|
:disabled="!canBid(req.name) || bidding[req.name]"
|
||||||
|
@click="submitBid(req)">
|
||||||
|
{{ bidding[req.name] ? '…' : '✓ Soumettre ma disponibilité' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sent confirmations -->
|
||||||
|
<div v-if="sentCount > 0" class="sent-banner">
|
||||||
|
✓ {{ sentCount }} soumission{{ sentCount > 1 ? 's' : '' }} envoyée{{ sentCount > 1 ? 's' : '' }} — en attente de confirmation du dispatcher
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bid-root {
|
||||||
|
--accent: #6366f1;
|
||||||
|
--bg: #0f1117;
|
||||||
|
--surface: rgba(255,255,255,0.04);
|
||||||
|
--surface2: rgba(255,255,255,0.07);
|
||||||
|
--border: rgba(255,255,255,0.09);
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--text2: #94a3b8;
|
||||||
|
--green: #10b981;
|
||||||
|
--red: #f43f5e;
|
||||||
|
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login ── */
|
||||||
|
.login-screen { min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
||||||
|
.login-card { text-align: center; max-width: 340px; width: 100%; }
|
||||||
|
.login-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||||
|
.login-card h2 { font-size: 1.5rem; font-weight: 800; margin-bottom: 0.5rem; }
|
||||||
|
.login-card p { color: var(--text2); margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||||||
|
.login-input { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 12px; padding: 0.85rem 1rem; color: var(--text); font-size: 1rem; margin-bottom: 1rem; box-sizing: border-box; }
|
||||||
|
.login-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.btn-login { width: 100%; background: var(--accent); border: none; color: white; border-radius: 12px; padding: 0.9rem; font-size: 1rem; font-weight: 700; cursor: pointer; }
|
||||||
|
.btn-login:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.bid-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; background: rgba(15,17,23,0.95); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
||||||
|
.bid-header-left { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.tech-avatar { width: 40px; height: 40px; background: rgba(99,102,241,0.2); border: 1px solid rgba(99,102,241,0.4); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 0.9rem; color: #818cf8; }
|
||||||
|
.tech-name { font-weight: 700; font-size: 0.95rem; }
|
||||||
|
.tech-sub { font-size: 0.72rem; color: var(--text2); }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.badge-sent { background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.3); color: var(--green); border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.72rem; font-weight: 700; }
|
||||||
|
.btn-refresh { background: var(--surface); border: 1px solid var(--border); color: var(--text2); border-radius: 8px; width: 34px; height: 34px; cursor: pointer; font-size: 1.1rem; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.spinning { display: inline-block; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── States ── */
|
||||||
|
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem 2rem; gap: 1rem; color: var(--text2); }
|
||||||
|
.spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
.empty-icon { font-size: 3rem; }
|
||||||
|
.btn-ghost { background: var(--surface); border: 1px solid var(--border); color: var(--text2); border-radius: 8px; padding: 0.5rem 1.25rem; cursor: pointer; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ── Request cards ── */
|
||||||
|
.requests-list { padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; }
|
||||||
|
.req-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 16px; overflow: hidden; transition: border-color 0.2s; }
|
||||||
|
.req-card.urgent { border-color: rgba(244,63,94,0.35); }
|
||||||
|
.req-card.expanded { border-color: var(--accent); }
|
||||||
|
.req-card-header { padding: 1rem; cursor: pointer; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.req-card-header:hover { background: var(--surface2); }
|
||||||
|
.req-type-badge { display: inline-flex; align-items: center; gap: 0.35rem; border: 1px solid; border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.72rem; font-weight: 700; width: fit-content; }
|
||||||
|
.urgent-badge { background: rgba(244,63,94,0.12); border: 1px solid rgba(244,63,94,0.3); color: var(--red); border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.72rem; font-weight: 700; width: fit-content; }
|
||||||
|
.req-problem { font-size: 0.95rem; font-weight: 700; }
|
||||||
|
.req-addr { font-size: 0.8rem; color: var(--text2); }
|
||||||
|
.req-dates-preview { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.date-chip { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); color: #818cf8; border-radius: 6px; padding: 0.15rem 0.5rem; font-size: 0.7rem; font-weight: 600; }
|
||||||
|
.date-chip-1 { background: rgba(99,102,241,0.2); border-color: rgba(99,102,241,0.45); }
|
||||||
|
.req-meta { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text2); margin-top: 0.25rem; }
|
||||||
|
|
||||||
|
/* ── Bid form ── */
|
||||||
|
.req-bid-form { padding: 1rem; border-top: 1px solid var(--border); background: rgba(0,0,0,0.15); display: flex; flex-direction: column; gap: 0.85rem; }
|
||||||
|
.bid-section-title { font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text2); }
|
||||||
|
.bid-description { font-size: 0.85rem; color: var(--text2); background: var(--surface); border-radius: 8px; padding: 0.65rem 0.85rem; line-height: 1.5; }
|
||||||
|
.client-dates { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.client-date-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.6rem 0.75rem; cursor: pointer; text-align: center; min-width: 100px; transition: all 0.15s; display: flex; flex-direction: column; gap: 0.15rem; }
|
||||||
|
.client-date-btn.selected { border-color: var(--accent); background: rgba(99,102,241,0.12); }
|
||||||
|
.cd-priority { font-size: 0.62rem; color: var(--text2); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.cd-date { font-size: 0.8rem; font-weight: 700; color: var(--text); }
|
||||||
|
.cd-slot { font-size: 0.7rem; color: #818cf8; }
|
||||||
|
.alt-date-row { display: flex; gap: 0.5rem; }
|
||||||
|
.date-input { flex: 1; background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.6rem 0.75rem; color: var(--text); font-size: 0.85rem; }
|
||||||
|
.date-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.slot-select { flex: 1; background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.6rem 0.75rem; color: var(--text); font-size: 0.85rem; }
|
||||||
|
.slot-select:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.duration-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.dur-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.45rem 0.85rem; cursor: pointer; font-size: 0.85rem; font-weight: 700; color: var(--text2); transition: all 0.12s; }
|
||||||
|
.dur-btn.selected { border-color: var(--accent); color: white; background: rgba(99,102,241,0.2); }
|
||||||
|
.notes-input { background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.65rem 0.85rem; color: var(--text); font-size: 0.85rem; resize: vertical; font-family: inherit; width: 100%; box-sizing: border-box; }
|
||||||
|
.notes-input:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.bid-actions { display: flex; gap: 0.75rem; }
|
||||||
|
.btn-decline { background: rgba(244,63,94,0.1); border: 1px solid rgba(244,63,94,0.25); color: var(--red); border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; font-size: 0.85rem; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.btn-accept { flex: 1; background: var(--accent); border: none; color: white; border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; font-size: 0.9rem; font-weight: 700; transition: opacity 0.15s; }
|
||||||
|
.btn-accept:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Price input ── */
|
||||||
|
.price-row { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.price-input-wrap { display: flex; align-items: center; background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0 0.85rem; gap: 0.4rem; }
|
||||||
|
.price-input-wrap:focus-within { border-color: var(--accent); }
|
||||||
|
.price-currency { color: var(--text2); font-weight: 700; font-size: 1rem; }
|
||||||
|
.price-input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-size: 1.05rem; font-weight: 700; padding: 0.65rem 0; width: 0; min-width: 60px; font-family: inherit; }
|
||||||
|
.price-unit { color: var(--text2); font-size: 0.78rem; }
|
||||||
|
.price-hint { font-size: 0.75rem; color: var(--text2); padding: 0.35rem 0; }
|
||||||
|
.price-hint strong { color: #a5b4fc; }
|
||||||
|
.required-star { color: var(--red); }
|
||||||
|
.budget-pill { background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.25); color: var(--green); border-radius: 20px; padding: 0.15rem 0.5rem; font-size: 0.68rem; font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── Sent banner ── */
|
||||||
|
.sent-banner { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.3); color: var(--green); border-radius: 12px; padding: 0.75rem 1.5rem; font-size: 0.82rem; font-weight: 600; text-align: center; backdrop-filter: blur(12px); }
|
||||||
|
</style>
|
||||||
16
apps/dispatch/src/router/index.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { route } from 'quasar/wrappers'
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
// Routes — add pages here; no change needed in stores or API
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: () => import('pages/DispatchV2Page.vue') },
|
||||||
|
{ path: '/mobile', component: () => import('pages/MobilePage.vue') },
|
||||||
|
{ path: '/admin', component: () => import('pages/AdminPage.vue') },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default route(function () {
|
||||||
|
return createRouter({
|
||||||
|
history: createWebHashHistory(process.env.VUE_ROUTER_BASE),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
})
|
||||||
51
apps/dispatch/src/stores/auth.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// ── Auth store — Authentik forwardAuth ──────────────────────────────────────
|
||||||
|
// Authentik handles login at the Traefik level. If the user reaches the app,
|
||||||
|
// they are already authenticated. We fetch their identity from the /api/ proxy
|
||||||
|
// which forwards Authentik headers to ERPNext.
|
||||||
|
// ERPNext API calls use a service token (not user session).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
// Service token for ERPNext API — all dispatch API calls use this
|
||||||
|
const ERP_SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function checkSession () {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// Fetch user identity — the /api/ proxy passes Authentik headers to ERPNext
|
||||||
|
// We use the service token to query who we are
|
||||||
|
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
|
||||||
|
headers: { Authorization: 'token ' + ERP_SERVICE_TOKEN },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
// For now, use the service account identity
|
||||||
|
// The actual Authentik user email is in the response headers (X-authentik-email)
|
||||||
|
// but those are only available at the Traefik level
|
||||||
|
user.value = data.message || 'authenticated'
|
||||||
|
} else {
|
||||||
|
user.value = 'authenticated' // Authentik guarantees auth, ERPNext may not know the user
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
user.value = 'authenticated' // If ERPNext is down, user is still authenticated via Authentik
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout () {
|
||||||
|
// Redirect to Authentik logout
|
||||||
|
window.location.href = 'https://auth.targo.ca/application/o/gigafibre-dispatch/end-session/'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, loading, error, checkSession, doLogin: checkSession, doLogout }
|
||||||
|
})
|
||||||
|
|
||||||
|
export function getServiceToken () { return ERP_SERVICE_TOKEN }
|
||||||
417
apps/dispatch/src/stores/dispatch.js
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
// ── Dispatch store ───────────────────────────────────────────────────────────
|
||||||
|
// Shared state for both MobilePage and DispatchPage.
|
||||||
|
// All ERPNext calls go through api/dispatch.js — not here.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch'
|
||||||
|
import { fetchDevices, fetchPositions, createTraccarSession } from 'src/api/traccar'
|
||||||
|
import { TECH_COLORS } from 'src/config/erpnext'
|
||||||
|
import { serializeAssistants } from 'src/composables/useHelpers'
|
||||||
|
|
||||||
|
// Module-level GPS guards — survive store re-creation and component remount
|
||||||
|
let __gpsStarted = false
|
||||||
|
let __gpsInterval = null
|
||||||
|
let __gpsPolling = false
|
||||||
|
|
||||||
|
export const useDispatchStore = defineStore('dispatch', () => {
|
||||||
|
const technicians = ref([])
|
||||||
|
const jobs = ref([])
|
||||||
|
const allTags = ref([]) // { name, label, color, category }
|
||||||
|
const loading = ref(false)
|
||||||
|
const erpStatus = ref('pending') // 'pending' | 'ok' | 'error' | 'session_expired'
|
||||||
|
|
||||||
|
// ── Data transformers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _mapJob (j) {
|
||||||
|
return {
|
||||||
|
id: j.ticket_id || j.name,
|
||||||
|
name: j.name, // ERPNext docname (used for PUT calls)
|
||||||
|
subject: j.subject || 'Job sans titre',
|
||||||
|
address: j.address || 'Adresse inconnue',
|
||||||
|
coords: [j.longitude || 0, j.latitude || 0],
|
||||||
|
priority: j.priority || 'low',
|
||||||
|
duration: j.duration_h || 1,
|
||||||
|
status: j.status || 'open',
|
||||||
|
assignedTech: j.assigned_tech || null,
|
||||||
|
routeOrder: j.route_order || 0,
|
||||||
|
legDist: j.leg_distance || null,
|
||||||
|
legDur: j.leg_duration || null,
|
||||||
|
scheduledDate: j.scheduled_date || null,
|
||||||
|
endDate: j.end_date || null,
|
||||||
|
startTime: j.start_time || null,
|
||||||
|
assistants: (j.assistants || []).map(a => ({ techId: a.tech_id, techName: a.tech_name, duration: a.duration_h || 0, note: a.note || '', pinned: !!a.pinned })),
|
||||||
|
tags: (j.tags || []).map(t => t.tag),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _mapTech (t, idx) {
|
||||||
|
return {
|
||||||
|
id: t.technician_id || t.name,
|
||||||
|
name: t.name, // ERPNext docname
|
||||||
|
fullName: t.full_name || t.name,
|
||||||
|
status: t.status || '',
|
||||||
|
user: t.user || null,
|
||||||
|
colorIdx: idx % TECH_COLORS.length,
|
||||||
|
coords: [t.longitude || -73.5673, t.latitude || 45.5017],
|
||||||
|
gpsCoords: null, // live GPS from Traccar (updated by polling)
|
||||||
|
gpsSpeed: 0,
|
||||||
|
gpsTime: null,
|
||||||
|
gpsOnline: false,
|
||||||
|
traccarDeviceId: t.traccar_device_id || null,
|
||||||
|
phone: t.phone || '',
|
||||||
|
email: t.email || '',
|
||||||
|
queue: [], // filled in loadAll()
|
||||||
|
tags: (t.tags || []).map(tg => tg.tag),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loaders ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadAll () {
|
||||||
|
loading.value = true
|
||||||
|
erpStatus.value = 'pending'
|
||||||
|
try {
|
||||||
|
const [rawTechs, rawJobs, rawTags] = await Promise.all([
|
||||||
|
fetchTechnicians(),
|
||||||
|
fetchJobs(),
|
||||||
|
fetchTags(),
|
||||||
|
])
|
||||||
|
allTags.value = rawTags
|
||||||
|
technicians.value = rawTechs.map(_mapTech)
|
||||||
|
jobs.value = rawJobs.map(_mapJob)
|
||||||
|
// Build each tech's ordered queue (primary + assistant jobs)
|
||||||
|
technicians.value.forEach(tech => {
|
||||||
|
tech.queue = jobs.value
|
||||||
|
.filter(j => j.assignedTech === tech.id)
|
||||||
|
.sort((a, b) => a.routeOrder - b.routeOrder)
|
||||||
|
tech.assistJobs = jobs.value
|
||||||
|
.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||||
|
})
|
||||||
|
erpStatus.value = 'ok'
|
||||||
|
} catch (e) {
|
||||||
|
erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error'
|
||||||
|
console.error('loadAll error:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load jobs assigned to one tech — used by MobilePage
|
||||||
|
async function loadJobsForTech (techId) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const raw = await fetchJobs([['assigned_tech', '=', techId]])
|
||||||
|
jobs.value = raw.map(_mapJob)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations (also syncs to ERPNext) ────────────────────────────────────
|
||||||
|
|
||||||
|
async function setJobStatus (jobId, status) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
job.status = status
|
||||||
|
await updateJob(job.id, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
// Remove from old tech queue
|
||||||
|
technicians.value.forEach(t => {
|
||||||
|
t.queue = t.queue.filter(q => q.id !== jobId)
|
||||||
|
})
|
||||||
|
// Add to new tech queue
|
||||||
|
const tech = technicians.value.find(t => t.id === techId)
|
||||||
|
if (tech) {
|
||||||
|
job.assignedTech = techId
|
||||||
|
job.routeOrder = routeOrder
|
||||||
|
job.status = 'assigned'
|
||||||
|
if (scheduledDate !== undefined) job.scheduledDate = scheduledDate
|
||||||
|
tech.queue.splice(routeOrder, 0, job)
|
||||||
|
// Re-number route_order
|
||||||
|
tech.queue.forEach((q, i) => { q.routeOrder = i })
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
assigned_tech: techId,
|
||||||
|
route_order: routeOrder,
|
||||||
|
status: 'assigned',
|
||||||
|
}
|
||||||
|
if (scheduledDate !== undefined) payload.scheduled_date = scheduledDate || ''
|
||||||
|
await updateJob(job.id, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unassignJob (jobId) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
|
||||||
|
job.assignedTech = null
|
||||||
|
job.status = 'open'
|
||||||
|
try { await updateJob(job.name || job.id, { assigned_tech: null, status: 'open' }) } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createJob (fields) {
|
||||||
|
// fields: { subject, address, duration_h, priority, assigned_tech?, scheduled_date?, start_time? }
|
||||||
|
const localId = 'WO-' + Date.now().toString(36).toUpperCase()
|
||||||
|
const job = _mapJob({
|
||||||
|
ticket_id: localId, name: localId,
|
||||||
|
subject: fields.subject || 'Nouveau travail',
|
||||||
|
address: fields.address || '',
|
||||||
|
longitude: fields.longitude || 0,
|
||||||
|
latitude: fields.latitude || 0,
|
||||||
|
duration_h: parseFloat(fields.duration_h) || 1,
|
||||||
|
priority: fields.priority || 'low',
|
||||||
|
status: fields.assigned_tech ? 'assigned' : 'open',
|
||||||
|
assigned_tech: fields.assigned_tech || null,
|
||||||
|
scheduled_date: fields.scheduled_date || null,
|
||||||
|
start_time: fields.start_time || null,
|
||||||
|
route_order: 0,
|
||||||
|
})
|
||||||
|
jobs.value.push(job)
|
||||||
|
if (fields.assigned_tech) {
|
||||||
|
const tech = technicians.value.find(t => t.id === fields.assigned_tech)
|
||||||
|
if (tech) { job.routeOrder = tech.queue.length; tech.queue.push(job) }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await apiCreateJob({
|
||||||
|
subject: job.subject,
|
||||||
|
address: job.address,
|
||||||
|
longitude: job.coords?.[0] || '',
|
||||||
|
latitude: job.coords?.[1] || '',
|
||||||
|
duration_h: job.duration,
|
||||||
|
priority: job.priority,
|
||||||
|
status: job.status,
|
||||||
|
assigned_tech: job.assignedTech || '',
|
||||||
|
scheduled_date: job.scheduledDate || '',
|
||||||
|
start_time: job.startTime || '',
|
||||||
|
})
|
||||||
|
if (created?.name) { job.id = created.name; job.name = created.name }
|
||||||
|
} catch (_) {}
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setJobSchedule (jobId, scheduledDate, startTime) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
job.scheduledDate = scheduledDate || null
|
||||||
|
job.startTime = startTime !== undefined ? startTime : job.startTime
|
||||||
|
const payload = { scheduled_date: job.scheduledDate || '' }
|
||||||
|
if (startTime !== undefined) payload.start_time = startTime || ''
|
||||||
|
try { await updateJob(job.name || job.id, payload) } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateJobCoords (jobId, lng, lat) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
job.coords = [lng, lat]
|
||||||
|
try { await updateJob(job.name || job.id, { longitude: lng, latitude: lat }) } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAssistant (jobId, techId) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
if (job.assignedTech === techId) return // already lead
|
||||||
|
if (job.assistants.some(a => a.techId === techId)) return // already assistant
|
||||||
|
const tech = technicians.value.find(t => t.id === techId)
|
||||||
|
const entry = { techId, techName: tech?.fullName || techId, duration: job.duration, note: '', pinned: false }
|
||||||
|
job.assistants = [...job.assistants, entry]
|
||||||
|
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||||
|
try {
|
||||||
|
await updateJob(job.name || job.id, {
|
||||||
|
assistants: serializeAssistants(job.assistants),
|
||||||
|
})
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAssistant (jobId, techId) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
job.assistants = job.assistants.filter(a => a.techId !== techId)
|
||||||
|
const tech = technicians.value.find(t => t.id === techId)
|
||||||
|
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||||
|
try {
|
||||||
|
await updateJob(job.name || job.id, {
|
||||||
|
assistants: serializeAssistants(job.assistants),
|
||||||
|
})
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderTechQueue (techId, fromIdx, toIdx) {
|
||||||
|
const tech = technicians.value.find(t => t.id === techId)
|
||||||
|
if (!tech) return
|
||||||
|
const [moved] = tech.queue.splice(fromIdx, 1)
|
||||||
|
tech.queue.splice(toIdx, 0, moved)
|
||||||
|
tech.queue.forEach((q, i) => { q.routeOrder = i })
|
||||||
|
// Sync all reordered jobs
|
||||||
|
await Promise.all(
|
||||||
|
tech.queue.map((q, i) => updateJob(q.id, { route_order: i })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Smart assign (removes circular assistant deps) ──────────────────────
|
||||||
|
function smartAssign (jobId, newTechId, dateStr) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
if (job.assistants.some(a => a.techId === newTechId)) {
|
||||||
|
job.assistants = job.assistants.filter(a => a.techId !== newTechId)
|
||||||
|
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
|
||||||
|
}
|
||||||
|
assignJobToTech(jobId, newTechId, technicians.value.find(t => t.id === newTechId)?.queue.length || 0, dateStr)
|
||||||
|
_rebuildAssistJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full unassign (clears assistants + unassigns) ──────────────────────
|
||||||
|
function fullUnassign (jobId) {
|
||||||
|
const job = jobs.value.find(j => j.id === jobId)
|
||||||
|
if (!job) return
|
||||||
|
if (job.assistants.length) { job.assistants = []; updateJob(job.name || job.id, { assistants: [] }).catch(() => {}) }
|
||||||
|
unassignJob(jobId)
|
||||||
|
_rebuildAssistJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild all tech.assistJobs references
|
||||||
|
function _rebuildAssistJobs () {
|
||||||
|
technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Traccar GPS — Hybrid: REST initial + WebSocket real-time ─────────────────
|
||||||
|
const traccarDevices = ref([])
|
||||||
|
const _techsByDevice = {} // deviceId (number) → tech object
|
||||||
|
|
||||||
|
function _buildTechDeviceMap () {
|
||||||
|
Object.keys(_techsByDevice).forEach(k => delete _techsByDevice[k])
|
||||||
|
technicians.value.forEach(t => {
|
||||||
|
if (!t.traccarDeviceId) return
|
||||||
|
const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId)
|
||||||
|
if (dev) _techsByDevice[dev.id] = t
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyPositions (positions) {
|
||||||
|
positions.forEach(p => {
|
||||||
|
const tech = _techsByDevice[p.deviceId]
|
||||||
|
if (!tech || !p.latitude || !p.longitude) return
|
||||||
|
const cur = tech.gpsCoords
|
||||||
|
if (!cur || Math.abs(cur[0] - p.longitude) > 0.00001 || Math.abs(cur[1] - p.latitude) > 0.00001) {
|
||||||
|
tech.gpsCoords = [p.longitude, p.latitude]
|
||||||
|
}
|
||||||
|
tech.gpsSpeed = p.speed || 0
|
||||||
|
tech.gpsTime = p.fixTime
|
||||||
|
tech.gpsOnline = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot REST fetch (manual refresh button + initial load)
|
||||||
|
async function pollGps () {
|
||||||
|
if (__gpsPolling) return
|
||||||
|
__gpsPolling = true
|
||||||
|
try {
|
||||||
|
if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices()
|
||||||
|
_buildTechDeviceMap()
|
||||||
|
const deviceIds = Object.keys(_techsByDevice).map(Number)
|
||||||
|
if (!deviceIds.length) return
|
||||||
|
const positions = await fetchPositions(deviceIds)
|
||||||
|
_applyPositions(positions)
|
||||||
|
Object.values(_techsByDevice).forEach(t => {
|
||||||
|
if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false
|
||||||
|
})
|
||||||
|
} catch (e) { console.warn('[GPS] Poll error:', e.message) }
|
||||||
|
finally { __gpsPolling = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket connection with auto-reconnect
|
||||||
|
let __ws = null
|
||||||
|
let __wsBackoff = 1000
|
||||||
|
|
||||||
|
function _connectWs () {
|
||||||
|
if (__ws) return
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const url = proto + '//' + window.location.host + '/traccar/api/socket'
|
||||||
|
try { __ws = new WebSocket(url) } catch (e) { console.warn('[GPS] WS error:', e); return }
|
||||||
|
__ws.onopen = () => {
|
||||||
|
__wsBackoff = 1000
|
||||||
|
// WS connected — stop fallback polling
|
||||||
|
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
|
||||||
|
console.log('[GPS] WebSocket connected — real-time updates active')
|
||||||
|
}
|
||||||
|
__ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
if (data.positions?.length) {
|
||||||
|
_buildTechDeviceMap() // refresh map in case techs changed
|
||||||
|
_applyPositions(data.positions)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
__ws.onerror = () => {}
|
||||||
|
__ws.onclose = () => {
|
||||||
|
__ws = null
|
||||||
|
if (!__gpsStarted) return
|
||||||
|
// Start fallback polling while WS is down
|
||||||
|
if (!__gpsInterval) {
|
||||||
|
__gpsInterval = setInterval(pollGps, 30000)
|
||||||
|
console.log('[GPS] WS closed — fallback to 30s polling')
|
||||||
|
}
|
||||||
|
setTimeout(_connectWs, __wsBackoff)
|
||||||
|
__wsBackoff = Math.min(__wsBackoff * 2, 60000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGpsTracking () {
|
||||||
|
if (__gpsStarted) return
|
||||||
|
__gpsStarted = true
|
||||||
|
// 1. Load devices + initial REST fetch (all last-known positions)
|
||||||
|
await pollGps()
|
||||||
|
console.log('[GPS] Initial positions loaded via REST')
|
||||||
|
// 2. Create session cookie for WebSocket auth, then connect
|
||||||
|
const sessionOk = await createTraccarSession()
|
||||||
|
if (sessionOk) {
|
||||||
|
_connectWs()
|
||||||
|
} else {
|
||||||
|
// Session failed — fall back to polling
|
||||||
|
__gpsInterval = setInterval(pollGps, 30000)
|
||||||
|
console.log('[GPS] Session failed — fallback to 30s polling')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopGpsTracking () {
|
||||||
|
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
|
||||||
|
if (__ws) { const ws = __ws; __ws = null; ws.onclose = null; ws.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startGpsPolling = startGpsTracking
|
||||||
|
const stopGpsPolling = stopGpsTracking
|
||||||
|
|
||||||
|
// ── Create / Delete technician ─────────────────────────────────────────────
|
||||||
|
async function createTechnician (fields) {
|
||||||
|
// Auto-generate technician_id: TECH-N+1
|
||||||
|
const maxNum = technicians.value.reduce((max, t) => {
|
||||||
|
const m = (t.id || '').match(/TECH-(\d+)/)
|
||||||
|
return m ? Math.max(max, parseInt(m[1])) : max
|
||||||
|
}, 0)
|
||||||
|
fields.technician_id = 'TECH-' + (maxNum + 1)
|
||||||
|
const doc = await apiCreateTech(fields)
|
||||||
|
const tech = _mapTech(doc, technicians.value.length)
|
||||||
|
technicians.value.push(tech)
|
||||||
|
return tech
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTechnician (techId) {
|
||||||
|
const tech = technicians.value.find(t => t.id === techId)
|
||||||
|
if (!tech) return
|
||||||
|
await apiDeleteTech(tech.name)
|
||||||
|
technicians.value = technicians.value.filter(t => t.id !== techId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
technicians, jobs, allTags, loading, erpStatus, traccarDevices,
|
||||||
|
loadAll, loadJobsForTech,
|
||||||
|
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
|
||||||
|
smartAssign, fullUnassign,
|
||||||
|
pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling,
|
||||||
|
createTechnician, deleteTechnician,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -15,7 +15,7 @@ services:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.field.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/field`)"
|
- "traefik.http.routers.field.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/field`)"
|
||||||
- "traefik.http.routers.field.entrypoints=web,websecure"
|
- "traefik.http.routers.field.entrypoints=web,websecure"
|
||||||
- "traefik.http.routers.field.middlewares=authentik@file,field-strip@docker"
|
- "traefik.http.routers.field.middlewares=authentik-client@file,field-strip@docker"
|
||||||
- "traefik.http.routers.field.service=field"
|
- "traefik.http.routers.field.service=field"
|
||||||
- "traefik.http.routers.field.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.field.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.field.priority=200"
|
- "traefik.http.routers.field.priority=200"
|
||||||
|
|
|
||||||
|
|
@ -16,30 +16,15 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ollama Vision API proxy — for bill/invoice OCR (legacy, optional)
|
# Ollama Vision API proxy — for bill/invoice OCR
|
||||||
location /ollama/ {
|
location /ollama/ {
|
||||||
resolver 127.0.0.11 valid=10s;
|
proxy_pass http://ollama:11434/;
|
||||||
set $ollama_upstream http://ollama:11434;
|
|
||||||
proxy_pass $ollama_upstream/;
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
client_max_body_size 20m;
|
client_max_body_size 20m;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Targo Hub API proxy — vision, devices, etc.
|
|
||||||
location /hub/ {
|
|
||||||
resolver 127.0.0.11 valid=10s;
|
|
||||||
set $hub_upstream http://targo-hub:3300;
|
|
||||||
proxy_pass $hub_upstream/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Authentik-Email $http_x_authentik_email;
|
|
||||||
proxy_set_header X-Authentik-Username $http_x_authentik_username;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
client_max_body_size 20m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,7 @@ module.exports = configure(function () {
|
||||||
},
|
},
|
||||||
|
|
||||||
framework: {
|
framework: {
|
||||||
config: {
|
config: {},
|
||||||
notify: { position: 'top', timeout: 2500 },
|
|
||||||
},
|
|
||||||
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog', 'BottomSheet'],
|
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog', 'BottomSheet'],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ export function authFetch (url, opts = {}) {
|
||||||
opts.headers = { ...opts.headers }
|
opts.headers = { ...opts.headers }
|
||||||
}
|
}
|
||||||
opts.redirect = 'manual'
|
opts.redirect = 'manual'
|
||||||
|
if (opts.method && opts.method !== 'GET') {
|
||||||
|
opts.credentials = 'omit'
|
||||||
|
}
|
||||||
return fetch(url, opts).then(res => {
|
return fetch(url, opts).then(res => {
|
||||||
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) {
|
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { authFetch } from './auth'
|
import { authFetch } from './auth'
|
||||||
|
|
||||||
const OLLAMA_URL = '/ollama/api/generate'
|
const OLLAMA_URL = '/ollama/api/generate'
|
||||||
const HUB_VISION_URL = 'https://msg.gigafibre.ca/vision/barcodes'
|
|
||||||
|
|
||||||
const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation.
|
const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation.
|
||||||
|
|
||||||
|
|
@ -67,28 +66,6 @@ export async function ocrBill (base64Image) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Send image to Gemini Vision (via targo-hub) for barcode/serial extraction.
|
|
||||||
* @param {string} base64Image — base64 or data URI
|
|
||||||
* @returns {{ barcodes: string[] }}
|
|
||||||
*/
|
|
||||||
export async function scanBarcodes (base64Image) {
|
|
||||||
// Direct call to targo-hub (cross-origin, no auth needed)
|
|
||||||
const res = await fetch(HUB_VISION_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ image: base64Image }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text()
|
|
||||||
throw new Error('Vision scan failed: ' + (text || res.status))
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
return { barcodes: data.barcodes || [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if Ollama is running and the vision model is available.
|
* Check if Ollama is running and the vision model is available.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,111 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { scanBarcodes } from 'src/api/ocr'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Barcode scanner using device camera photo capture + Gemini Vision AI.
|
* Multi-barcode scanner from camera photo.
|
||||||
*
|
* Takes a picture, splits into horizontal strips, scans each for barcodes.
|
||||||
* Strategy: Use <input type="file" capture="environment"> which triggers
|
* Also supports live scanning mode.
|
||||||
* the native camera app — this gives proper autofocus, tap-to-focus,
|
|
||||||
* and high-res photos. Then send to Gemini Vision for barcode extraction.
|
|
||||||
*
|
|
||||||
* Also keeps a thumbnail of each captured photo for reference.
|
|
||||||
*/
|
*/
|
||||||
export function useScanner () {
|
export function useScanner () {
|
||||||
const barcodes = ref([]) // Array of { value, region } — max 3
|
const barcodes = ref([]) // Array of { value, region } — max 3
|
||||||
const scanning = ref(false) // true while Gemini is processing
|
const scanning = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
|
let _scanner = null
|
||||||
const photos = ref([]) // all captured photo thumbnails
|
|
||||||
|
|
||||||
/**
|
// Scan a photo for up to 3 barcodes by splitting into strips
|
||||||
* Process a photo file from camera input.
|
async function scanPhoto (file) {
|
||||||
* Resizes for AI, keeps thumbnail, sends to Gemini.
|
|
||||||
* @param {File} file - image file from camera
|
|
||||||
* @returns {string[]} newly found barcode values
|
|
||||||
*/
|
|
||||||
async function processPhoto (file) {
|
|
||||||
if (!file) return []
|
|
||||||
error.value = null
|
error.value = null
|
||||||
|
barcodes.value = []
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
|
|
||||||
const found = []
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create thumbnail for display (small)
|
const { Html5Qrcode } = await import('html5-qrcode')
|
||||||
const thumbUrl = await resizeImage(file, 400)
|
const scanner = new Html5Qrcode('scanner-scratch', { verbose: false })
|
||||||
lastPhoto.value = thumbUrl
|
|
||||||
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [] })
|
|
||||||
|
|
||||||
// Create optimized image for AI — keep high res for text readability
|
// Load image as bitmap
|
||||||
const aiImage = await resizeImage(file, 1600, 0.92)
|
const img = await createImageBitmap(file)
|
||||||
|
const { width, height } = img
|
||||||
|
|
||||||
// Send to Gemini Vision
|
// Split into 3 horizontal strips and scan each
|
||||||
const result = await scanBarcodes(aiImage)
|
const strips = [
|
||||||
const existing = new Set(barcodes.value.map(b => b.value))
|
{ y: 0, h: Math.floor(height / 3), label: 'haut' },
|
||||||
|
{ y: Math.floor(height / 3), h: Math.floor(height / 3), label: 'milieu' },
|
||||||
|
{ y: Math.floor(height * 2 / 3), h: height - Math.floor(height * 2 / 3), label: 'bas' },
|
||||||
|
]
|
||||||
|
|
||||||
for (const code of (result.barcodes || [])) {
|
const canvas = document.createElement('canvas')
|
||||||
if (barcodes.value.length >= 3) break
|
const ctx = canvas.getContext('2d')
|
||||||
if (!existing.has(code)) {
|
const found = new Set()
|
||||||
existing.add(code)
|
|
||||||
barcodes.value.push({ value: code, region: 'photo' })
|
// First try the full image
|
||||||
found.push(code)
|
try {
|
||||||
|
const result = await scanner.scanFileV2(file, false)
|
||||||
|
if (result?.decodedText && !found.has(result.decodedText)) {
|
||||||
|
found.add(result.decodedText)
|
||||||
|
barcodes.value.push({ value: result.decodedText, region: 'complet' })
|
||||||
}
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Then try each strip for additional barcodes
|
||||||
|
for (const strip of strips) {
|
||||||
|
if (barcodes.value.length >= 3) break
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = strip.h
|
||||||
|
ctx.drawImage(img, 0, strip.y, width, strip.h, 0, 0, width, strip.h)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.9))
|
||||||
|
const stripFile = new File([blob], 'strip.jpg', { type: 'image/jpeg' })
|
||||||
|
const result = await scanner.scanFileV2(stripFile, false)
|
||||||
|
if (result?.decodedText && !found.has(result.decodedText)) {
|
||||||
|
found.add(result.decodedText)
|
||||||
|
barcodes.value.push({ value: result.decodedText, region: strip.label })
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag the photo with found codes
|
img.close()
|
||||||
const lastIdx = photos.value.length - 1
|
scanner.clear()
|
||||||
if (lastIdx >= 0) photos.value[lastIdx].codes = found
|
|
||||||
|
|
||||||
if (found.length === 0) {
|
if (barcodes.value.length === 0) {
|
||||||
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
error.value = 'Aucun code-barres détecté'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message || 'Erreur'
|
error.value = e.message || 'Erreur scanner'
|
||||||
} finally {
|
} finally {
|
||||||
scanning.value = false
|
scanning.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return found
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Live scanning mode — continuous camera feed
|
||||||
* Resize an image file to a max dimension, return as base64 data URI.
|
async function startLive (elementId, onDecode) {
|
||||||
*/
|
error.value = null
|
||||||
function resizeImage (file, maxDim, quality = 0.85) {
|
scanning.value = true
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
const img = new Image()
|
const { Html5Qrcode } = await import('html5-qrcode')
|
||||||
img.onload = () => {
|
_scanner = new Html5Qrcode(elementId, { verbose: false })
|
||||||
let { width, height } = img
|
await _scanner.start(
|
||||||
if (width > maxDim || height > maxDim) {
|
{ facingMode: 'environment' },
|
||||||
const ratio = Math.min(maxDim / width, maxDim / height)
|
{ fps: 10, qrbox: { width: 280, height: 100 } },
|
||||||
width = Math.round(width * ratio)
|
(decoded) => {
|
||||||
height = Math.round(height * ratio)
|
if (barcodes.value.length < 3 && !barcodes.value.find(b => b.value === decoded)) {
|
||||||
|
barcodes.value.push({ value: decoded, region: 'live' })
|
||||||
|
onDecode?.(decoded)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const canvas = document.createElement('canvas')
|
)
|
||||||
canvas.width = width
|
} catch (e) {
|
||||||
canvas.height = height
|
error.value = e.message || 'Caméra non disponible'
|
||||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
|
scanning.value = false
|
||||||
resolve(canvas.toDataURL('image/jpeg', quality))
|
}
|
||||||
}
|
}
|
||||||
img.onerror = reject
|
|
||||||
img.src = URL.createObjectURL(file)
|
async function stopLive () {
|
||||||
})
|
try {
|
||||||
|
if (_scanner?.isScanning) await _scanner.stop()
|
||||||
|
_scanner?.clear()
|
||||||
|
} catch {}
|
||||||
|
_scanner = null
|
||||||
|
scanning.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeBarcode (value) {
|
function removeBarcode (value) {
|
||||||
|
|
@ -99,12 +115,7 @@ export function useScanner () {
|
||||||
function clearBarcodes () {
|
function clearBarcodes () {
|
||||||
barcodes.value = []
|
barcodes.value = []
|
||||||
error.value = null
|
error.value = null
|
||||||
lastPhoto.value = null
|
|
||||||
photos.value = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { barcodes, scanning, error, scanPhoto, startLive, stopLive, removeBarcode, clearBarcodes }
|
||||||
barcodes, scanning, error, lastPhoto, photos,
|
|
||||||
processPhoto, removeBarcode, clearBarcodes,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1 @@
|
||||||
// Route API calls through field-frontend nginx which injects the ERP token.
|
export const BASE_URL = ''
|
||||||
// Without this, POST/PUT/DELETE fail with 403 (CSRF) because they go directly
|
|
||||||
// to ERPNext via Traefik without the API token header.
|
|
||||||
export const BASE_URL = '/field'
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
Targo Field
|
Targo Field
|
||||||
</q-toolbar-title>
|
</q-toolbar-title>
|
||||||
<q-badge v-if="offline.pendingCount > 0" color="orange" :label="offline.pendingCount + ' en attente'" class="q-mr-sm" />
|
<q-badge v-if="offline.pendingCount > 0" color="orange" :label="offline.pendingCount + ' en attente'" class="q-mr-sm" />
|
||||||
|
<q-badge v-if="!offline.online" color="red" label="Hors ligne" />
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
|
|
||||||
|
|
@ -14,15 +15,8 @@
|
||||||
<router-view />
|
<router-view />
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
|
|
||||||
<!-- Bottom section: offline banner + tabs -->
|
<!-- Bottom tab bar -->
|
||||||
<q-footer class="bg-white text-dark" bordered>
|
<q-footer class="bg-white text-dark" bordered>
|
||||||
<!-- Offline banner above tabs -->
|
|
||||||
<transition name="slide-down">
|
|
||||||
<div v-if="!offline.online" class="offline-banner">
|
|
||||||
<q-icon name="wifi_off" size="16px" class="q-mr-xs" />
|
|
||||||
Hors ligne
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
<q-tabs v-model="tab" dense no-caps active-color="primary" indicator-color="primary" class="field-tabs">
|
<q-tabs v-model="tab" dense no-caps active-color="primary" indicator-color="primary" class="field-tabs">
|
||||||
<q-route-tab name="tasks" icon="assignment" label="Tâches" to="/" exact />
|
<q-route-tab name="tasks" icon="assignment" label="Tâches" to="/" exact />
|
||||||
<q-route-tab name="scan" icon="qr_code_scanner" label="Scanner" to="/scan" />
|
<q-route-tab name="scan" icon="qr_code_scanner" label="Scanner" to="/scan" />
|
||||||
|
|
@ -48,31 +42,4 @@ const offline = useOfflineStore()
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.offline-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #c62828;
|
|
||||||
color: white;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-down-enter-active,
|
|
||||||
.slide-down-leave-active {
|
|
||||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.slide-down-enter-from,
|
|
||||||
.slide-down-leave-to {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.slide-down-enter-to,
|
|
||||||
.slide-down-leave-from {
|
|
||||||
max-height: 30px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,55 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding class="scan-page">
|
<q-page padding>
|
||||||
<!-- Job context banner -->
|
<div class="text-h6 q-mb-md">Scanner</div>
|
||||||
<q-card v-if="jobContext" flat bordered class="q-mb-md bg-blue-1">
|
|
||||||
<q-card-section class="q-py-sm row items-center no-wrap">
|
|
||||||
<q-icon name="work" color="primary" class="q-mr-sm" />
|
|
||||||
<div class="col">
|
|
||||||
<div class="text-subtitle2">{{ jobContext.customer_name || jobContext.customer }}</div>
|
|
||||||
<div class="text-caption text-grey" v-if="jobContext.location_name">
|
|
||||||
<q-icon name="place" size="xs" /> {{ jobContext.location_name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-btn flat dense size="sm" icon="close" @click="jobContext = null" />
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section class="q-pt-none q-pb-sm text-caption text-blue-grey">
|
|
||||||
Les équipements scannés seront automatiquement liés à ce client et cette adresse.
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- Camera capture button -->
|
<!-- Mode toggle -->
|
||||||
<div class="text-center">
|
<q-tabs v-model="mode" dense no-caps active-color="primary" class="q-mb-md">
|
||||||
<q-btn
|
<q-tab name="photo" icon="photo_camera" label="Photo" />
|
||||||
color="primary" icon="photo_camera" label="Scanner"
|
<q-tab name="live" icon="videocam" label="Live" />
|
||||||
size="lg" rounded unelevated
|
<q-tab name="manual" icon="keyboard" label="Manuel" />
|
||||||
@click="takePhoto"
|
</q-tabs>
|
||||||
:loading="scanner.scanning.value"
|
|
||||||
class="q-px-xl"
|
<!-- Photo mode: take picture, scan for up to 3 barcodes -->
|
||||||
/>
|
<div v-if="mode === 'photo'" class="text-center">
|
||||||
|
<q-btn color="primary" icon="photo_camera" label="Prendre une photo" size="lg" @click="triggerCamera" :loading="scanner.scanning.value" />
|
||||||
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
|
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
|
||||||
|
<div v-if="photoPreview" class="q-mt-md">
|
||||||
|
<img :src="photoPreview" style="max-width: 100%; max-height: 300px; border-radius: 8px" />
|
||||||
|
</div>
|
||||||
|
<div v-if="scanner.error.value" class="text-negative q-mt-sm">{{ scanner.error.value }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Last captured photo (thumbnail) -->
|
<!-- Live mode -->
|
||||||
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
<div v-if="mode === 'live'">
|
||||||
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
<div id="live-reader" style="width: 100%; max-width: 400px; margin: 0 auto" />
|
||||||
<div v-if="scanner.scanning.value" class="preview-overlay">
|
<div class="text-center q-mt-sm">
|
||||||
<q-spinner-dots size="32px" color="white" />
|
<q-btn v-if="!scanner.scanning.value" color="primary" label="Démarrer" @click="startLive" />
|
||||||
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
|
<q-btn v-else color="negative" label="Arrêter" @click="scanner.stopLive()" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error / status -->
|
<!-- Manual entry -->
|
||||||
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
|
<div v-if="mode === 'manual'">
|
||||||
{{ scanner.error.value }}
|
<q-input v-model="manualCode" label="Code-barres / SN / MAC" outlined dense class="q-mb-sm"
|
||||||
|
@keyup.enter="addManual">
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-btn flat dense icon="add" @click="addManual" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual entry -->
|
<!-- Hidden element for scanner scratch space -->
|
||||||
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC" outlined dense class="q-mt-md"
|
<div id="scanner-scratch" style="display:none" />
|
||||||
@keyup.enter="addManual">
|
|
||||||
<template v-slot:append>
|
|
||||||
<q-btn flat dense icon="add" @click="addManual" :disable="!manualCode.trim()" />
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<!-- Scanned barcodes -->
|
<!-- Scanned barcodes -->
|
||||||
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
|
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-lg">
|
||||||
<div class="text-subtitle2 q-mb-xs">Codes détectés ({{ scanner.barcodes.value.length }}/3)</div>
|
<div class="text-subtitle2 q-mb-sm">Codes détectés ({{ scanner.barcodes.value.length }}/3)</div>
|
||||||
<q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm">
|
<q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm">
|
||||||
<q-card-section class="q-py-sm row items-center no-wrap">
|
<q-card-section class="q-py-sm row items-center no-wrap">
|
||||||
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
|
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="text-subtitle2 mono">{{ bc.value }}</div>
|
<div class="text-subtitle2" style="font-family: monospace">{{ bc.value }}</div>
|
||||||
|
<div class="text-caption text-grey">{{ bc.region }}</div>
|
||||||
</div>
|
</div>
|
||||||
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
|
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
|
||||||
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
|
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
|
||||||
|
|
@ -75,16 +66,8 @@
|
||||||
<div class="text-caption">
|
<div class="text-caption">
|
||||||
Client: {{ lookupResults[bc.value].equipment.customer_name || lookupResults[bc.value].equipment.customer || 'Aucun' }}
|
Client: {{ lookupResults[bc.value].equipment.customer_name || lookupResults[bc.value].equipment.customer || 'Aucun' }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!lookupResults[bc.value].equipment.service_location && !jobContext" class="q-mt-xs">
|
<q-btn flat dense size="sm" label="Voir détails" icon="open_in_new" class="q-mt-xs"
|
||||||
<q-btn flat dense size="sm" color="orange" label="Lier à un service" icon="link"
|
@click="$router.push({ name: 'device', params: { serial: bc.value } })" />
|
||||||
@click="openLinkDialog(bc.value, lookupResults[bc.value].equipment)" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-caption text-green q-mt-xs">
|
|
||||||
<q-icon name="check_circle" size="xs" class="q-mr-xs" />
|
|
||||||
{{ lookupResults[bc.value].equipment.service_location }}
|
|
||||||
</div>
|
|
||||||
<q-btn flat dense size="sm" label="Détails" icon="open_in_new" class="q-mt-xs"
|
|
||||||
@click="$router.push({ name: 'device', params: { serial: lookupResults[bc.value].equipment.serial_number || bc.value } })" />
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<q-badge color="orange" label="Non trouvé" class="q-mb-xs" />
|
<q-badge color="orange" label="Non trouvé" class="q-mb-xs" />
|
||||||
|
|
@ -95,36 +78,12 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Photo history (small thumbnails) -->
|
<!-- Link all scanned devices to account -->
|
||||||
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
|
<div v-if="scanner.barcodes.value.length > 0 && jobContext" class="q-mt-md">
|
||||||
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
|
<q-btn color="primary" icon="link" :label="'Lier au client ' + (jobContext.customer || '')"
|
||||||
<div class="row q-gutter-xs">
|
@click="linkAllToAccount" :loading="linking" class="full-width" />
|
||||||
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
|
|
||||||
<img :src="p.url" />
|
|
||||||
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Link all to account (manual, when no job context) -->
|
|
||||||
<div v-if="scanner.barcodes.value.length > 0 && !jobContext && hasUnlinked" class="q-mt-sm">
|
|
||||||
<q-btn color="orange" icon="link" label="Lier les équipements à un service"
|
|
||||||
@click="openLinkDialogForAll" outline class="full-width" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Full photo viewer -->
|
|
||||||
<q-dialog v-model="showFullPhoto" maximized>
|
|
||||||
<q-card class="bg-black column">
|
|
||||||
<q-card-section class="col-auto row items-center">
|
|
||||||
<div class="text-white text-subtitle2 col">Photo</div>
|
|
||||||
<q-btn flat round icon="close" color="white" v-close-popup />
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section class="col column items-center justify-center">
|
|
||||||
<img :src="fullPhotoUrl" style="max-width:100%; max-height:80vh; object-fit:contain" />
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
|
|
||||||
<!-- Create equipment dialog -->
|
<!-- Create equipment dialog -->
|
||||||
<q-dialog v-model="createDialog">
|
<q-dialog v-model="createDialog">
|
||||||
<q-card style="min-width: 320px">
|
<q-card style="min-width: 320px">
|
||||||
|
|
@ -144,58 +103,11 @@
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<!-- Link device to service dialog -->
|
|
||||||
<q-dialog v-model="linkDialog">
|
|
||||||
<q-card style="min-width: 340px">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h6">Lier à un service</div>
|
|
||||||
<div class="text-caption text-grey mono">{{ linkTarget?.serial_number }}</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section>
|
|
||||||
<q-input v-model="linkSearch" label="Rechercher client" outlined dense class="q-mb-sm"
|
|
||||||
@update:model-value="searchCustomers" debounce="400">
|
|
||||||
<template v-slot:append><q-icon name="search" /></template>
|
|
||||||
</q-input>
|
|
||||||
<q-list v-if="customerResults.length" bordered separator class="q-mb-sm" style="max-height: 150px; overflow-y: auto">
|
|
||||||
<q-item v-for="c in customerResults" :key="c.name" clickable @click="selectCustomer(c)">
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>{{ c.customer_name || c.name }}</q-item-label>
|
|
||||||
<q-item-label caption>{{ c.name }}</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
<div v-if="selectedCustomer">
|
|
||||||
<div class="text-subtitle2 q-mb-xs">{{ selectedCustomer.customer_name || selectedCustomer.name }}</div>
|
|
||||||
<div v-if="loadingLocations" class="text-center q-py-sm"><q-spinner size="sm" /></div>
|
|
||||||
<q-list v-else-if="serviceLocations.length" bordered separator>
|
|
||||||
<q-item v-for="loc in serviceLocations" :key="loc.name" clickable
|
|
||||||
:class="{ 'bg-blue-1': selectedLocation?.name === loc.name }"
|
|
||||||
@click="selectedLocation = loc">
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>{{ loc.location_name || loc.name }}</q-item-label>
|
|
||||||
<q-item-label caption>{{ loc.address_line }} {{ loc.city }}</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side v-if="selectedLocation?.name === loc.name">
|
|
||||||
<q-icon name="check_circle" color="primary" />
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
<div v-else class="text-caption text-grey">Aucune adresse de service</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-actions align="right">
|
|
||||||
<q-btn flat label="Annuler" v-close-popup />
|
|
||||||
<q-btn color="primary" label="Lier" :disable="!selectedCustomer || !selectedLocation"
|
|
||||||
@click="linkDeviceToService" :loading="linkingSingle" />
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useScanner } from 'src/composables/useScanner'
|
import { useScanner } from 'src/composables/useScanner'
|
||||||
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
|
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
|
||||||
|
|
@ -206,72 +118,38 @@ const route = useRoute()
|
||||||
const scanner = useScanner()
|
const scanner = useScanner()
|
||||||
const offline = useOfflineStore()
|
const offline = useOfflineStore()
|
||||||
|
|
||||||
|
const mode = ref('photo')
|
||||||
const cameraInput = ref(null)
|
const cameraInput = ref(null)
|
||||||
|
const photoPreview = ref(null)
|
||||||
const manualCode = ref('')
|
const manualCode = ref('')
|
||||||
const lookingUp = ref(null)
|
const lookingUp = ref(null)
|
||||||
const lookupResults = ref({})
|
const lookupResults = ref({})
|
||||||
|
const linking = ref(false)
|
||||||
const createDialog = ref(false)
|
const createDialog = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
|
|
||||||
// Photo viewer
|
|
||||||
const showFullPhoto = ref(false)
|
|
||||||
const fullPhotoUrl = ref('')
|
|
||||||
|
|
||||||
// Link dialog
|
|
||||||
const linkDialog = ref(false)
|
|
||||||
const linkTarget = ref(null)
|
|
||||||
const linkTargetBarcode = ref('')
|
|
||||||
const linkSearch = ref('')
|
|
||||||
const customerResults = ref([])
|
|
||||||
const selectedCustomer = ref(null)
|
|
||||||
const serviceLocations = ref([])
|
|
||||||
const selectedLocation = ref(null)
|
|
||||||
const loadingLocations = ref(false)
|
|
||||||
const linkingSingle = ref(false)
|
|
||||||
|
|
||||||
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
|
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
|
||||||
const jobContext = ref(route.query.job ? {
|
|
||||||
job: route.query.job,
|
// Job context from query params (when coming from TasksPage)
|
||||||
customer: route.query.customer,
|
const jobContext = ref(route.query.job ? { job: route.query.job, customer: route.query.customer } : null)
|
||||||
customer_name: route.query.customer_name,
|
|
||||||
location: route.query.location,
|
|
||||||
location_name: route.query.location_name,
|
|
||||||
} : null)
|
|
||||||
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
|
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
|
||||||
|
|
||||||
const hasUnlinked = computed(() =>
|
function triggerCamera () {
|
||||||
scanner.barcodes.value.some(bc => {
|
|
||||||
const r = lookupResults.value[bc.value]
|
|
||||||
return r?.found && !r.equipment.service_location
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Camera ---
|
|
||||||
|
|
||||||
function takePhoto () {
|
|
||||||
// Reset the input so same file triggers change
|
|
||||||
if (cameraInput.value) cameraInput.value.value = ''
|
|
||||||
cameraInput.value?.click()
|
cameraInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPhoto (e) {
|
async function onPhoto (e) {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
photoPreview.value = URL.createObjectURL(file)
|
||||||
const found = await scanner.processPhoto(file)
|
await scanner.scanPhoto(file)
|
||||||
for (const code of found) {
|
// Auto-lookup found barcodes
|
||||||
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
|
for (const bc of scanner.barcodes.value) {
|
||||||
lookupDevice(code)
|
lookupDevice(bc.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewPhoto (photo) {
|
|
||||||
fullPhotoUrl.value = photo.url
|
|
||||||
showFullPhoto.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Manual entry ---
|
|
||||||
|
|
||||||
function addManual () {
|
function addManual () {
|
||||||
const code = manualCode.value.trim()
|
const code = manualCode.value.trim()
|
||||||
if (!code) return
|
if (!code) return
|
||||||
|
|
@ -286,7 +164,12 @@ function addManual () {
|
||||||
manualCode.value = ''
|
manualCode.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Device lookup ---
|
async function startLive () {
|
||||||
|
await scanner.startLive('live-reader', (decoded) => {
|
||||||
|
Notify.create({ type: 'positive', message: 'Scanné: ' + decoded, timeout: 1500 })
|
||||||
|
lookupDevice(decoded)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function lookupDevice (serial) {
|
async function lookupDevice (serial) {
|
||||||
lookingUp.value = serial
|
lookingUp.value = serial
|
||||||
|
|
@ -299,73 +182,27 @@ async function lookupDevice (serial) {
|
||||||
})
|
})
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
lookupResults.value[serial] = { found: true, equipment: results[0] }
|
lookupResults.value[serial] = { found: true, equipment: results[0] }
|
||||||
return
|
} else {
|
||||||
}
|
// Also try barcode field
|
||||||
const byBarcode = await listDocs('Service Equipment', {
|
const byBarcode = await listDocs('Service Equipment', {
|
||||||
filters: { barcode: serial },
|
filters: { barcode: serial },
|
||||||
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
|
|
||||||
'service_location', 'status', 'mac_address'],
|
|
||||||
limit: 1,
|
|
||||||
})
|
|
||||||
if (byBarcode.length > 0) {
|
|
||||||
lookupResults.value[serial] = { found: true, equipment: byBarcode[0] }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const normalized = serial.replace(/[:\-\.]/g, '').toUpperCase()
|
|
||||||
if (normalized.length === 12 && /^[A-F0-9]+$/.test(normalized)) {
|
|
||||||
const byMac = await listDocs('Service Equipment', {
|
|
||||||
filters: { mac_address: ['like', `%${normalized.slice(-6)}%`] },
|
|
||||||
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
|
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
|
||||||
'service_location', 'status', 'mac_address'],
|
'service_location', 'status', 'mac_address'],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
})
|
})
|
||||||
if (byMac.length > 0) {
|
if (byBarcode.length > 0) {
|
||||||
lookupResults.value[serial] = { found: true, equipment: byMac[0] }
|
lookupResults.value[serial] = { found: true, equipment: byBarcode[0] }
|
||||||
return
|
} else {
|
||||||
|
lookupResults.value[serial] = { found: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lookupResults.value[serial] = { found: false }
|
|
||||||
} catch {
|
} catch {
|
||||||
lookupResults.value[serial] = { found: false }
|
lookupResults.value[serial] = { found: false }
|
||||||
} finally {
|
} finally {
|
||||||
lookingUp.value = null
|
lookingUp.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-link to job context if device found but not yet linked
|
|
||||||
const result = lookupResults.value[serial]
|
|
||||||
if (result?.found && jobContext.value?.customer && !result.equipment.service_location) {
|
|
||||||
await autoLinkToJob(serial, result.equipment)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auto-link device to job context ---
|
|
||||||
|
|
||||||
async function autoLinkToJob (serial, equipment) {
|
|
||||||
if (!jobContext.value?.customer) return
|
|
||||||
const updates = { customer: jobContext.value.customer }
|
|
||||||
if (jobContext.value.location) updates.service_location = jobContext.value.location
|
|
||||||
try {
|
|
||||||
await updateDoc('Service Equipment', equipment.name, updates)
|
|
||||||
equipment.customer = jobContext.value.customer
|
|
||||||
equipment.customer_name = jobContext.value.customer_name
|
|
||||||
if (jobContext.value.location) equipment.service_location = jobContext.value.location
|
|
||||||
// Update lookupResults
|
|
||||||
if (lookupResults.value[serial]) {
|
|
||||||
lookupResults.value[serial].equipment = { ...equipment }
|
|
||||||
}
|
|
||||||
Notify.create({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Lié à ' + (jobContext.value.customer_name || jobContext.value.customer),
|
|
||||||
caption: jobContext.value.location_name || undefined,
|
|
||||||
icon: 'link',
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur liaison: ' + e.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Create equipment ---
|
|
||||||
|
|
||||||
function openCreateDialog (serial) {
|
function openCreateDialog (serial) {
|
||||||
newEquip.value = { serial_number: serial, equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
|
newEquip.value = { serial_number: serial, equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
|
||||||
createDialog.value = true
|
createDialog.value = true
|
||||||
|
|
@ -377,13 +214,12 @@ async function createEquipment () {
|
||||||
...newEquip.value,
|
...newEquip.value,
|
||||||
status: 'Actif',
|
status: 'Actif',
|
||||||
customer: jobContext.value?.customer || '',
|
customer: jobContext.value?.customer || '',
|
||||||
service_location: jobContext.value?.location || '',
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (offline.online) {
|
if (offline.online) {
|
||||||
const doc = await createDoc('Service Equipment', data)
|
const doc = await createDoc('Service Equipment', data)
|
||||||
lookupResults.value[data.serial_number] = { found: true, equipment: doc }
|
lookupResults.value[data.serial_number] = { found: true, equipment: doc }
|
||||||
Notify.create({ type: 'positive', message: 'Équipement créé' })
|
Notify.create({ type: 'positive', message: 'Équipement créé: ' + doc.name })
|
||||||
} else {
|
} else {
|
||||||
await offline.enqueue({ type: 'create', doctype: 'Service Equipment', data })
|
await offline.enqueue({ type: 'create', doctype: 'Service Equipment', data })
|
||||||
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
|
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
|
||||||
|
|
@ -396,130 +232,25 @@ async function createEquipment () {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Link dialog for unlinked devices (no job context) ---
|
async function linkAllToAccount () {
|
||||||
|
if (!jobContext.value?.customer) return
|
||||||
function openLinkDialogForAll () {
|
linking.value = true
|
||||||
// Find first unlinked device
|
let linked = 0
|
||||||
for (const bc of scanner.barcodes.value) {
|
for (const bc of scanner.barcodes.value) {
|
||||||
const r = lookupResults.value[bc.value]
|
const result = lookupResults.value[bc.value]
|
||||||
if (r?.found && !r.equipment.service_location) {
|
if (result?.found && result.equipment.name) {
|
||||||
openLinkDialog(bc.value, r.equipment)
|
try {
|
||||||
return
|
await updateDoc('Service Equipment', result.equipment.name, { customer: jobContext.value.customer })
|
||||||
|
result.equipment.customer = jobContext.value.customer
|
||||||
|
linked++
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
linking.value = false
|
||||||
|
Notify.create({ type: 'positive', message: linked + ' équipement(s) lié(s)' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Link device to service ---
|
onBeforeUnmount(() => {
|
||||||
|
scanner.stopLive()
|
||||||
function openLinkDialog (barcode, equipment) {
|
})
|
||||||
linkTarget.value = equipment
|
|
||||||
linkTargetBarcode.value = barcode
|
|
||||||
linkSearch.value = ''
|
|
||||||
customerResults.value = []
|
|
||||||
selectedCustomer.value = null
|
|
||||||
serviceLocations.value = []
|
|
||||||
selectedLocation.value = null
|
|
||||||
if (equipment.customer) {
|
|
||||||
selectedCustomer.value = { name: equipment.customer, customer_name: equipment.customer_name }
|
|
||||||
loadServiceLocations(equipment.customer)
|
|
||||||
}
|
|
||||||
linkDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchCustomers (text) {
|
|
||||||
if (!text || text.length < 2) { customerResults.value = []; return }
|
|
||||||
try {
|
|
||||||
customerResults.value = await listDocs('Customer', {
|
|
||||||
filters: { customer_name: ['like', `%${text}%`] },
|
|
||||||
fields: ['name', 'customer_name'],
|
|
||||||
limit: 10,
|
|
||||||
})
|
|
||||||
} catch { customerResults.value = [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectCustomer (customer) {
|
|
||||||
selectedCustomer.value = customer
|
|
||||||
customerResults.value = []
|
|
||||||
linkSearch.value = ''
|
|
||||||
selectedLocation.value = null
|
|
||||||
await loadServiceLocations(customer.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadServiceLocations (customerId) {
|
|
||||||
loadingLocations.value = true
|
|
||||||
try {
|
|
||||||
serviceLocations.value = await listDocs('Service Location', {
|
|
||||||
filters: { customer: customerId },
|
|
||||||
fields: ['name', 'location_name', 'address_line', 'city', 'connection_type'],
|
|
||||||
limit: 50,
|
|
||||||
})
|
|
||||||
} catch { serviceLocations.value = [] }
|
|
||||||
finally { loadingLocations.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function linkDeviceToService () {
|
|
||||||
if (!linkTarget.value || !selectedCustomer.value || !selectedLocation.value) return
|
|
||||||
linkingSingle.value = true
|
|
||||||
try {
|
|
||||||
await updateDoc('Service Equipment', linkTarget.value.name, {
|
|
||||||
customer: selectedCustomer.value.name,
|
|
||||||
service_location: selectedLocation.value.name,
|
|
||||||
})
|
|
||||||
linkTarget.value.customer = selectedCustomer.value.name
|
|
||||||
linkTarget.value.customer_name = selectedCustomer.value.customer_name
|
|
||||||
linkTarget.value.service_location = selectedLocation.value.name
|
|
||||||
if (lookupResults.value[linkTargetBarcode.value]) {
|
|
||||||
lookupResults.value[linkTargetBarcode.value].equipment = { ...linkTarget.value }
|
|
||||||
}
|
|
||||||
Notify.create({ type: 'positive', message: 'Lié à ' + (selectedLocation.value.location_name || selectedLocation.value.name) })
|
|
||||||
linkDialog.value = false
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
linkingSingle.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.scan-page {
|
|
||||||
padding-bottom: 16px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo-preview {
|
|
||||||
position: relative;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 250px;
|
|
||||||
border-radius: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo-thumb {
|
|
||||||
position: relative;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,7 @@
|
||||||
<q-btn size="sm" color="positive" label="Terminer" icon="check"
|
<q-btn size="sm" color="positive" label="Terminer" icon="check"
|
||||||
v-if="job.status === 'In Progress'" @click.stop="updateJobStatus(job, 'Completed')" />
|
v-if="job.status === 'In Progress'" @click.stop="updateJobStatus(job, 'Completed')" />
|
||||||
<q-btn size="sm" flat label="Scanner" icon="qr_code_scanner"
|
<q-btn size="sm" flat label="Scanner" icon="qr_code_scanner"
|
||||||
@click.stop="$router.push({ name: 'scan', query: {
|
@click.stop="$router.push({ name: 'scan', query: { job: job.name, customer: job.customer } })" />
|
||||||
job: job.name,
|
|
||||||
customer: job.customer,
|
|
||||||
customer_name: job.customer_name,
|
|
||||||
location: job.service_location,
|
|
||||||
location_name: job.service_location_name,
|
|
||||||
} })" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-slide-transition>
|
</q-slide-transition>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ import createQuasarApp from './app.js'
|
||||||
import quasarUserOptions from './quasar-user-options.js'
|
import quasarUserOptions from './quasar-user-options.js'
|
||||||
|
|
||||||
|
|
||||||
|
import 'app/src-pwa/register-service-worker'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
resolver 127.0.0.11 valid=30s;
|
|
||||||
|
|
||||||
# ERPNext API proxy — token injected server-side (never in JS bundle)
|
# ERPNext API proxy — token injected server-side (never in JS bundle)
|
||||||
# To rotate: edit this file + docker restart ops-frontend
|
# To rotate: edit this file + docker restart ops-frontend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
|
@ -19,10 +17,9 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ollama Vision API proxy — for bill/invoice OCR (dynamic resolve, won't crash if ollama is down)
|
# Ollama Vision API proxy — for bill/invoice OCR
|
||||||
location /ollama/ {
|
location /ollama/ {
|
||||||
set $ollama_upstream http://ollama:11434;
|
proxy_pass http://ollama:11434/;
|
||||||
proxy_pass $ollama_upstream/;
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
|
|
|
||||||