refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables

- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-08 17:38:38 -04:00
parent 838f8dcd8d
commit 320655b0a0
184 changed files with 20817 additions and 25897 deletions

View File

@ -0,0 +1,263 @@
<!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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>

View File

@ -7,5 +7,10 @@ import { onMounted } from 'vue'
import { useCustomerStore } from 'src/stores/customer'
const store = useCustomerStore()
onMounted(() => store.init())
onMounted(() => {
store.init().catch(() => {
// Auth not available (standalone portal mode) continue as guest
console.info('[Portal] Auth unavailable — running in guest mode')
})
})
</script>

View File

@ -0,0 +1,11 @@
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 })

View File

@ -4,6 +4,8 @@ import { BASE_URL } from 'src/config/erpnext'
async function apiGet (path) {
const res = await authFetch(BASE_URL + 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()
if (data.exc) throw new Error(data.exc)
return data

View File

@ -0,0 +1,49 @@
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 }
}

View File

@ -0,0 +1,72 @@
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 }
}

View File

@ -0,0 +1,21 @@
// 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' },
]

View File

@ -10,6 +10,9 @@
<span v-if="store.customerName" class="text-body2 q-mr-md gt-sm">
{{ store.customerName }}
</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-toolbar>
</q-header>
@ -31,13 +34,13 @@
</q-drawer>
<q-page-container>
<!-- Loading state -->
<div v-if="store.loading" class="flex flex-center" style="min-height: 60vh">
<!-- Loading state (only block if we expect auth to succeed) -->
<div v-if="store.loading && !guestMode" class="flex flex-center" style="min-height: 60vh">
<q-spinner-dots size="48px" color="primary" />
</div>
<!-- Error state -->
<div v-else-if="store.error" class="flex flex-center" style="min-height: 60vh">
<!-- Error state only block for auth-required pages, let catalog/cart through -->
<div v-else-if="store.error && requiresAuth" class="flex flex-center" style="min-height: 60vh">
<div class="text-center">
<q-icon name="error_outline" size="64px" color="negative" />
<div class="text-h6 q-mt-md">{{ store.error }}</div>
@ -52,19 +55,29 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useCustomerStore } from 'src/stores/customer'
import { useCartStore } from 'src/stores/cart'
import { logout } from 'src/api/auth'
const route = useRoute()
const store = useCustomerStore()
const cartStore = useCartStore()
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 = [
{ to: '/', icon: 'dashboard', label: 'Tableau de bord' },
{ to: '/invoices', icon: 'receipt_long', label: 'Factures' },
{ to: '/tickets', icon: 'support_agent', label: 'Support' },
{ to: '/messages', icon: 'chat', label: 'Messages' },
{ to: '/me', icon: 'person', label: 'Mon compte' },
{ to: '/catalogue', icon: 'storefront', label: 'Catalogue' },
]
function doLogout () {

View File

@ -0,0 +1,301 @@
<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>

View File

@ -0,0 +1,109 @@
<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>

View File

@ -0,0 +1,171 @@
<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>

View File

@ -12,6 +12,9 @@ const routes = [
{ path: 'tickets/:name', name: 'ticket-detail', component: () => import('pages/TicketDetailPage.vue') },
{ path: 'messages', name: 'messages', component: () => import('pages/MessagesPage.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') },
],
},
]

View File

@ -0,0 +1,98 @@
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,
}
})

View File

@ -0,0 +1,15 @@
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)
}

View File

@ -1,4 +0,0 @@
node_modules
dist
.git
*.md

View File

@ -1,10 +0,0 @@
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',
},
}

View File

@ -1,6 +0,0 @@
node_modules/
dist/
.quasar/
.DS_Store
*.log
npm-debug.log*

View File

@ -1,14 +0,0 @@
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`

View File

@ -1,29 +0,0 @@
#!/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"

View File

@ -1,41 +0,0 @@
#!/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"

View File

@ -1,23 +0,0 @@
"""
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()

View File

@ -1,99 +0,0 @@
"""
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()

View File

@ -1,14 +0,0 @@
<!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>

View File

@ -1,8 +0,0 @@
# ERPNext
ERPNEXT_VERSION=v15.49.2
DB_ROOT_PASSWORD=admin
# PostgreSQL (address autocomplete)
PG_DB=dispatch
PG_USER=dispatch
PG_PASSWORD=dispatch

View File

@ -1,138 +0,0 @@
# 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:

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +0,0 @@
{
"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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -1,75 +0,0 @@
/* 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 = '.'
},
},
}
})

View File

@ -1,88 +0,0 @@
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()

View File

@ -1,37 +0,0 @@
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()

View File

@ -1,42 +0,0 @@
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()

View File

@ -1,43 +0,0 @@
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()

View File

@ -1,40 +0,0 @@
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()

View File

@ -1,35 +0,0 @@
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()

View File

@ -1,52 +0,0 @@
#!/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()

View File

@ -1,30 +0,0 @@
/* 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$/] }
)
)
}

View File

@ -1,32 +0,0 @@
{
"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"
}
]
}

View File

@ -1,10 +0,0 @@
/* 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;
}
}

View File

@ -1,41 +0,0 @@
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)
}
})

View File

@ -1,11 +0,0 @@
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth'
const auth = useAuthStore()
onMounted(() => auth.checkSession())
</script>

View File

@ -1,44 +0,0 @@
// ── 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'
}

View File

@ -1,60 +0,0 @@
// ── 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
}

View File

@ -1,74 +0,0 @@
// ── 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
}

View File

@ -1,116 +0,0 @@
// ── 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
}

View File

@ -1,273 +0,0 @@
/**
* 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)
}
}

View File

@ -1,97 +0,0 @@
// ── 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
}

View File

@ -1,94 +0,0 @@
// ── 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 }

View File

@ -1,5 +0,0 @@
import { createPinia } from 'pinia'
export default ({ app }) => {
app.use(createPinia())
}

View File

@ -1,137 +0,0 @@
<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>

View File

@ -1,140 +0,0 @@
// ── 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 }
}

View File

@ -1,120 +0,0 @@
// ── 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,
}
}

View File

@ -1,242 +0,0 @@
// ── 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,
}
}

View File

@ -1,162 +0,0 @@
// ── 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
}

View File

@ -1,413 +0,0 @@
// ── 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,
}
}

View File

@ -1,209 +0,0 @@
// ── 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,
}
}

View File

@ -1,172 +0,0 @@
// ── 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,
}
}

View File

@ -1,78 +0,0 @@
// ── 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 }
}

View File

@ -1,26 +0,0 @@
// ── 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
]

View File

@ -1,41 +0,0 @@
// 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%; }

View File

@ -1,194 +0,0 @@
<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>

View File

@ -1,80 +0,0 @@
<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>

View File

@ -1,50 +0,0 @@
<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>

View File

@ -1,72 +0,0 @@
<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>

View File

@ -1,104 +0,0 @@
<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>

View File

@ -1,128 +0,0 @@
<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>

View File

@ -1,112 +0,0 @@
<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>

View File

@ -1,93 +0,0 @@
<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>

View File

@ -1,557 +0,0 @@
<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>

View File

@ -1,583 +0,0 @@
<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: '8h12h', icon: '🌅' },
{ id: 'afternoon', label: 'Après-midi', sub: '12h17h', icon: '☀️' },
{ id: 'evening', label: 'Soir', sub: '17h20h', icon: '🌙' },
]
const BUDGET_OPTIONS = [
{ id: 'b50', label: '50100 $', min: 50, max: 100 },
{ id: 'b100', label: '100200 $', min: 100, max: 200 },
{ id: 'b200', label: '200350 $', 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 &amp; 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>

View File

@ -1,716 +0,0 @@
<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 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,700 +0,0 @@
<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">&#8646;</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">&#9889;</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;">&#128119;</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é&nbsp;: <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 &rarr;' }}
</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;">&#128205; {{ activeJob.address }}</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<span class="chip">&#9202; {{ activeJob.duration }}h</span>
<span v-if="activeJob.legDur" class="chip">&#128664; {{ 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;">&#128205; {{ job.address }}</div>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;">
<span class="chip">&#9202; {{ job.duration }}h</span>
<span v-if="job.legDur" class="chip">&#128664; {{ 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 ? '&#8963;' : '&#8964;' }}</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">&#10003;</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;">&#128197;</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">
&#10003; É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;">&times;</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">
&#128247; 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;">&times;</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">
&#128247; 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;">&times;</button>
</div>
<div class="modal-row">
<div class="modal-row-icon">&#128205;</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">&#9202;</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">&#128664;</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">&#10003; {{ 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>

View File

@ -1,399 +0,0 @@
<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: '8h12h' },
{ id: 'afternoon', label: 'Après-midi', sub: '12h17h' },
{ id: 'evening', label: 'Soir', sub: '17h20h' },
{ 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>

View File

@ -1,16 +0,0 @@
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,
})
})

View File

@ -1,51 +0,0 @@
// ── 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 }

View File

@ -1,417 +0,0 @@
// ── 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,
}
})

View File

@ -15,7 +15,7 @@ services:
- "traefik.enable=true"
- "traefik.http.routers.field.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/field`)"
- "traefik.http.routers.field.entrypoints=web,websecure"
- "traefik.http.routers.field.middlewares=authentik-client@file,field-strip@docker"
- "traefik.http.routers.field.middlewares=authentik@file,field-strip@docker"
- "traefik.http.routers.field.service=field"
- "traefik.http.routers.field.tls.certresolver=letsencrypt"
- "traefik.http.routers.field.priority=200"

View File

@ -16,15 +16,30 @@ server {
proxy_set_header X-Forwarded-Proto https;
}
# Ollama Vision API proxy for bill/invoice OCR
# Ollama Vision API proxy for bill/invoice OCR (legacy, optional)
location /ollama/ {
proxy_pass http://ollama:11434/;
resolver 127.0.0.11 valid=10s;
set $ollama_upstream http://ollama:11434;
proxy_pass $ollama_upstream/;
proxy_set_header Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
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
location / {
try_files $uri $uri/ /index.html;

View File

@ -33,7 +33,9 @@ module.exports = configure(function () {
},
framework: {
config: {},
config: {
notify: { position: 'top', timeout: 2500 },
},
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog', 'BottomSheet'],
},

View File

@ -14,9 +14,6 @@ export function authFetch (url, opts = {}) {
opts.headers = { ...opts.headers }
}
opts.redirect = 'manual'
if (opts.method && opts.method !== 'GET') {
opts.credentials = 'omit'
}
return fetch(url, opts).then(res => {
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) {
window.location.reload()

View File

@ -1,6 +1,7 @@
import { authFetch } from './auth'
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.
@ -66,6 +67,28 @@ 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.
*/

View File

@ -1,111 +1,95 @@
import { ref } from 'vue'
import { scanBarcodes } from 'src/api/ocr'
/**
* Multi-barcode scanner from camera photo.
* Takes a picture, splits into horizontal strips, scans each for barcodes.
* Also supports live scanning mode.
* Barcode scanner using device camera photo capture + Gemini Vision AI.
*
* Strategy: Use <input type="file" capture="environment"> which triggers
* 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 () {
const barcodes = ref([]) // Array of { value, region } — max 3
const scanning = ref(false)
const scanning = ref(false) // true while Gemini is processing
const error = ref(null)
let _scanner = null
const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
const photos = ref([]) // all captured photo thumbnails
// Scan a photo for up to 3 barcodes by splitting into strips
async function scanPhoto (file) {
/**
* Process a photo file from camera input.
* 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
barcodes.value = []
scanning.value = true
const found = []
try {
const { Html5Qrcode } = await import('html5-qrcode')
const scanner = new Html5Qrcode('scanner-scratch', { verbose: false })
// Create thumbnail for display (small)
const thumbUrl = await resizeImage(file, 400)
lastPhoto.value = thumbUrl
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [] })
// Load image as bitmap
const img = await createImageBitmap(file)
const { width, height } = img
// Create optimized image for AI — keep high res for text readability
const aiImage = await resizeImage(file, 1600, 0.92)
// Split into 3 horizontal strips and scan each
const strips = [
{ 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' },
]
// Send to Gemini Vision
const result = await scanBarcodes(aiImage)
const existing = new Set(barcodes.value.map(b => b.value))
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const found = new Set()
// First try the full image
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) {
for (const code of (result.barcodes || [])) {
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 {}
if (!existing.has(code)) {
existing.add(code)
barcodes.value.push({ value: code, region: 'photo' })
found.push(code)
}
}
img.close()
scanner.clear()
// Tag the photo with found codes
const lastIdx = photos.value.length - 1
if (lastIdx >= 0) photos.value[lastIdx].codes = found
if (barcodes.value.length === 0) {
error.value = 'Aucun code-barres détecté'
if (found.length === 0) {
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
}
} catch (e) {
error.value = e.message || 'Erreur scanner'
error.value = e.message || 'Erreur'
} finally {
scanning.value = false
}
return found
}
// Live scanning mode — continuous camera feed
async function startLive (elementId, onDecode) {
error.value = null
scanning.value = true
try {
const { Html5Qrcode } = await import('html5-qrcode')
_scanner = new Html5Qrcode(elementId, { verbose: false })
await _scanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 280, height: 100 } },
(decoded) => {
if (barcodes.value.length < 3 && !barcodes.value.find(b => b.value === decoded)) {
barcodes.value.push({ value: decoded, region: 'live' })
onDecode?.(decoded)
}
/**
* Resize an image file to a max dimension, return as base64 data URI.
*/
function resizeImage (file, maxDim, quality = 0.85) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
let { width, height } = img
if (width > maxDim || height > maxDim) {
const ratio = Math.min(maxDim / width, maxDim / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
)
} catch (e) {
error.value = e.message || 'Caméra non disponible'
scanning.value = false
}
}
async function stopLive () {
try {
if (_scanner?.isScanning) await _scanner.stop()
_scanner?.clear()
} catch {}
_scanner = null
scanning.value = false
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', quality))
}
img.onerror = reject
img.src = URL.createObjectURL(file)
})
}
function removeBarcode (value) {
@ -115,7 +99,12 @@ export function useScanner () {
function clearBarcodes () {
barcodes.value = []
error.value = null
lastPhoto.value = null
photos.value = []
}
return { barcodes, scanning, error, scanPhoto, startLive, stopLive, removeBarcode, clearBarcodes }
return {
barcodes, scanning, error, lastPhoto, photos,
processPhoto, removeBarcode, clearBarcodes,
}
}

View File

@ -1 +1,4 @@
export const BASE_URL = ''
// Route API calls through field-frontend nginx which injects the ERP token.
// 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'

View File

@ -7,7 +7,6 @@
Targo Field
</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.online" color="red" label="Hors ligne" />
</q-toolbar>
</q-header>
@ -15,8 +14,15 @@
<router-view />
</q-page-container>
<!-- Bottom tab bar -->
<!-- Bottom section: offline banner + tabs -->
<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-route-tab name="tasks" icon="assignment" label="Tâches" to="/" exact />
<q-route-tab name="scan" icon="qr_code_scanner" label="Scanner" to="/scan" />
@ -42,4 +48,31 @@ const offline = useOfflineStore()
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>

View File

@ -1,55 +1,64 @@
<template>
<q-page padding>
<div class="text-h6 q-mb-md">Scanner</div>
<q-page padding class="scan-page">
<!-- Job context banner -->
<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>
<!-- Mode toggle -->
<q-tabs v-model="mode" dense no-caps active-color="primary" class="q-mb-md">
<q-tab name="photo" icon="photo_camera" label="Photo" />
<q-tab name="live" icon="videocam" label="Live" />
<q-tab name="manual" icon="keyboard" label="Manuel" />
</q-tabs>
<!-- 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" />
<!-- Camera capture button -->
<div class="text-center">
<q-btn
color="primary" icon="photo_camera" label="Scanner"
size="lg" rounded unelevated
@click="takePhoto"
:loading="scanner.scanning.value"
class="q-px-xl"
/>
<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>
<!-- Live mode -->
<div v-if="mode === 'live'">
<div id="live-reader" style="width: 100%; max-width: 400px; margin: 0 auto" />
<div class="text-center q-mt-sm">
<q-btn v-if="!scanner.scanning.value" color="primary" label="Démarrer" @click="startLive" />
<q-btn v-else color="negative" label="Arrêter" @click="scanner.stopLive()" />
<!-- Last captured photo (thumbnail) -->
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
<div v-if="scanner.scanning.value" class="preview-overlay">
<q-spinner-dots size="32px" color="white" />
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
</div>
</div>
<!-- Error / status -->
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
{{ scanner.error.value }}
</div>
<!-- Manual entry -->
<div v-if="mode === 'manual'">
<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>
<!-- Hidden element for scanner scratch space -->
<div id="scanner-scratch" style="display:none" />
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC" outlined dense class="q-mt-md"
@keyup.enter="addManual">
<template v-slot:append>
<q-btn flat dense icon="add" @click="addManual" :disable="!manualCode.trim()" />
</template>
</q-input>
<!-- Scanned barcodes -->
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-lg">
<div class="text-subtitle2 q-mb-sm">Codes détectés ({{ scanner.barcodes.value.length }}/3)</div>
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
<div class="text-subtitle2 q-mb-xs">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-section class="q-py-sm row items-center no-wrap">
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
<div class="col">
<div class="text-subtitle2" style="font-family: monospace">{{ bc.value }}</div>
<div class="text-caption text-grey">{{ bc.region }}</div>
<div class="text-subtitle2 mono">{{ bc.value }}</div>
</div>
<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)" />
@ -66,8 +75,16 @@
<div class="text-caption">
Client: {{ lookupResults[bc.value].equipment.customer_name || lookupResults[bc.value].equipment.customer || 'Aucun' }}
</div>
<q-btn flat dense size="sm" label="Voir détails" icon="open_in_new" class="q-mt-xs"
@click="$router.push({ name: 'device', params: { serial: bc.value } })" />
<div v-if="!lookupResults[bc.value].equipment.service_location && !jobContext" class="q-mt-xs">
<q-btn flat dense size="sm" color="orange" label="Lier à un service" icon="link"
@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 v-else>
<q-badge color="orange" label="Non trouvé" class="q-mb-xs" />
@ -78,12 +95,36 @@
</q-card>
</div>
<!-- Link all scanned devices to account -->
<div v-if="scanner.barcodes.value.length > 0 && jobContext" class="q-mt-md">
<q-btn color="primary" icon="link" :label="'Lier au client ' + (jobContext.customer || '')"
@click="linkAllToAccount" :loading="linking" class="full-width" />
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs">
<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>
<!-- 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 -->
<q-dialog v-model="createDialog">
<q-card style="min-width: 320px">
@ -103,11 +144,58 @@
</q-card-actions>
</q-card>
</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>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useScanner } from 'src/composables/useScanner'
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
@ -118,38 +206,72 @@ const route = useRoute()
const scanner = useScanner()
const offline = useOfflineStore()
const mode = ref('photo')
const cameraInput = ref(null)
const photoPreview = ref(null)
const manualCode = ref('')
const lookingUp = ref(null)
const lookupResults = ref({})
const linking = ref(false)
const createDialog = 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']
// Job context from query params (when coming from TasksPage)
const jobContext = ref(route.query.job ? { job: route.query.job, customer: route.query.customer } : null)
const jobContext = ref(route.query.job ? {
job: route.query.job,
customer: route.query.customer,
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: '' })
function triggerCamera () {
const hasUnlinked = computed(() =>
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()
}
async function onPhoto (e) {
const file = e.target.files?.[0]
if (!file) return
photoPreview.value = URL.createObjectURL(file)
await scanner.scanPhoto(file)
// Auto-lookup found barcodes
for (const bc of scanner.barcodes.value) {
lookupDevice(bc.value)
const found = await scanner.processPhoto(file)
for (const code of found) {
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
lookupDevice(code)
}
}
function viewPhoto (photo) {
fullPhotoUrl.value = photo.url
showFullPhoto.value = true
}
// --- Manual entry ---
function addManual () {
const code = manualCode.value.trim()
if (!code) return
@ -164,12 +286,7 @@ function addManual () {
manualCode.value = ''
}
async function startLive () {
await scanner.startLive('live-reader', (decoded) => {
Notify.create({ type: 'positive', message: 'Scanné: ' + decoded, timeout: 1500 })
lookupDevice(decoded)
})
}
// --- Device lookup ---
async function lookupDevice (serial) {
lookingUp.value = serial
@ -182,27 +299,73 @@ async function lookupDevice (serial) {
})
if (results.length > 0) {
lookupResults.value[serial] = { found: true, equipment: results[0] }
} else {
// Also try barcode field
const byBarcode = await listDocs('Service Equipment', {
filters: { barcode: serial },
return
}
const byBarcode = await listDocs('Service Equipment', {
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',
'service_location', 'status', 'mac_address'],
limit: 1,
})
if (byBarcode.length > 0) {
lookupResults.value[serial] = { found: true, equipment: byBarcode[0] }
} else {
lookupResults.value[serial] = { found: false }
if (byMac.length > 0) {
lookupResults.value[serial] = { found: true, equipment: byMac[0] }
return
}
}
lookupResults.value[serial] = { found: false }
} catch {
lookupResults.value[serial] = { found: false }
} finally {
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) {
newEquip.value = { serial_number: serial, equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
createDialog.value = true
@ -214,12 +377,13 @@ async function createEquipment () {
...newEquip.value,
status: 'Actif',
customer: jobContext.value?.customer || '',
service_location: jobContext.value?.location || '',
}
try {
if (offline.online) {
const doc = await createDoc('Service Equipment', data)
lookupResults.value[data.serial_number] = { found: true, equipment: doc }
Notify.create({ type: 'positive', message: 'Équipement créé: ' + doc.name })
Notify.create({ type: 'positive', message: 'Équipement créé' })
} else {
await offline.enqueue({ type: 'create', doctype: 'Service Equipment', data })
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
@ -232,25 +396,130 @@ async function createEquipment () {
}
}
async function linkAllToAccount () {
if (!jobContext.value?.customer) return
linking.value = true
let linked = 0
// --- Link dialog for unlinked devices (no job context) ---
function openLinkDialogForAll () {
// Find first unlinked device
for (const bc of scanner.barcodes.value) {
const result = lookupResults.value[bc.value]
if (result?.found && result.equipment.name) {
try {
await updateDoc('Service Equipment', result.equipment.name, { customer: jobContext.value.customer })
result.equipment.customer = jobContext.value.customer
linked++
} catch {}
const r = lookupResults.value[bc.value]
if (r?.found && !r.equipment.service_location) {
openLinkDialog(bc.value, r.equipment)
return
}
}
linking.value = false
Notify.create({ type: 'positive', message: linked + ' équipement(s) lié(s)' })
}
onBeforeUnmount(() => {
scanner.stopLive()
})
// --- Link device to service ---
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>
<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>

View File

@ -42,7 +42,13 @@
<q-btn size="sm" color="positive" label="Terminer" icon="check"
v-if="job.status === 'In Progress'" @click.stop="updateJobStatus(job, 'Completed')" />
<q-btn size="sm" flat label="Scanner" icon="qr_code_scanner"
@click.stop="$router.push({ name: 'scan', query: { job: job.name, customer: job.customer } })" />
@click.stop="$router.push({ name: 'scan', query: {
job: job.name,
customer: job.customer,
customer_name: job.customer_name,
location: job.service_location,
location_name: job.service_location_name,
} })" />
</div>
</div>
</q-slide-transition>

View File

@ -38,8 +38,6 @@ import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
import 'app/src-pwa/register-service-worker'

View File

@ -25,6 +25,15 @@ export function authFetch (url, opts = {}) {
}
export async function getLoggedUser () {
// First try nginx whoami endpoint (returns Authentik email header)
try {
const res = await fetch(BASE_URL + '/auth/whoami')
if (res.ok) {
const data = await res.json()
if (data.email && data.email !== '') return data.email
}
} catch {}
// Fallback: ask ERPNext (returns API token owner, usually "Administrator")
try {
const headers = SERVICE_TOKEN ? { Authorization: 'token ' + SERVICE_TOKEN } : {}
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { headers })
@ -37,5 +46,5 @@ export async function getLoggedUser () {
}
export async function logout () {
window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/'
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
}

Some files were not shown because too many files have changed in this diff Show More