'use strict' const cfg = require('./config') const { log, json, erpFetch } = require('./helpers') const { verifyJwt } = require('./magic-link') const { extractField } = require('./vision') const ui = require('./ui') // ── Tech mobile SPA ────────────────────────────────────────────────────────── // Server-rendered shell + hash-routed client views (home/hist/cal/profile/job-detail). // All UI primitives come from ../ui (design.css, components, client.js, scanner.js). // All jobs pre-loaded and embedded as JSON; detail view pops open instantly from // the cache and async-refreshes notes / photos / equipment. // // Field-targeted scans: each form field has a 📷 that opens a Gemini-backed // single-field extractor — "find the Wi-Fi password on this label" rather than // "read everything". Tech approves or retries the value before it fills the input. // ═════════════════════════════════════════════════════════════════════════════ // Auth + request helpers // ═════════════════════════════════════════════════════════════════════════════ function authToken (path) { const m = path.match(/^\/t\/([A-Za-z0-9_\-\.]+)/) return m ? verifyJwt(m[1]) : null } async function readBody (req) { const chunks = [] for await (const c of req) chunks.push(c) try { return JSON.parse(Buffer.concat(chunks).toString()) } catch { return null } } // ═════════════════════════════════════════════════════════════════════════════ // Route dispatcher // ═════════════════════════════════════════════════════════════════════════════ async function route (req, res, method, path) { if (method === 'GET' && /^\/t\/[A-Za-z0-9_\-\.]+$/.test(path)) return handlePage(req, res, path) if (method === 'POST' && path.endsWith('/status')) return handleStatus(req, res, path) if (method === 'POST' && path.endsWith('/note')) return handleNote(req, res, path) if (method === 'POST' && path.endsWith('/photo')) return handlePhotoUpload(req, res, path) if (method === 'GET' && path.endsWith('/photos')) return handlePhotoList(req, res, path) if (method === 'GET' && path.endsWith('/photo-serve')) return handlePhotoServe(req, res, path) if (method === 'GET' && path.endsWith('/job')) return handleJobDetail(req, res, path) if (method === 'POST' && path.endsWith('/field-scan')) return handleFieldScan(req, res, path) if (method === 'POST' && path.endsWith('/scan')) return handleScan(req, res, path) if (method === 'POST' && path.endsWith('/vision')) return handleVision(req, res, path) if (method === 'POST' && path.endsWith('/equip')) return handleEquipInstall(req, res, path) if (method === 'POST' && path.endsWith('/equip-remove')) return handleEquipRemove(req, res, path) if (method === 'GET' && path.endsWith('/catalog')) return handleCatalog(req, res, path) if (method === 'GET' && path.endsWith('/equip-list')) return handleEquipList(req, res, path) return json(res, 404, { error: 'Not found' }) } // ═════════════════════════════════════════════════════════════════════════════ // Page shell + data load // ═════════════════════════════════════════════════════════════════════════════ async function handlePage (req, res, path) { const payload = authToken(path) if (!payload) { res.writeHead(200, ui.htmlHeaders()); return res.end(ui.pageExpired()) } const techId = payload.sub // Montreal local time — UTC midnight rolls over 4-5 h before Montreal midnight, // which would mislabel evening jobs as "hier". const today = ui.montrealDate() const rearDate = ui.montrealDate(new Date(Date.now() - 60 * 86400 * 1000)) const frontDate = ui.montrealDate(new Date(Date.now() + 60 * 86400 * 1000)) try { const techRes = await erpFetch(`/api/resource/Dispatch%20Technician/${encodeURIComponent(techId)}?fields=${encodeURIComponent(JSON.stringify(['name', 'full_name', 'phone', 'email', 'assigned_group']))}`) const tech = techRes.status === 200 && techRes.data?.data ? techRes.data.data : { name: techId, full_name: techId } // Frappe v16 blocks fetched/linked fields (customer_name, service_location_name) // and fields not in DocField. Real time field is `start_time`. We fetch link // IDs and resolve names in two batch follow-up queries. const fields = JSON.stringify(['name', 'subject', 'status', 'customer', 'service_location', 'start_time', 'scheduled_date', 'duration_h', 'priority', 'job_type', 'notes', 'assigned_group', 'address', 'ticket_id', 'source_issue', 'actual_start', 'actual_end']) const filters = JSON.stringify([ ['assigned_tech', '=', techId], ['scheduled_date', 'between', [rearDate, frontDate]], ]) const jobsRes = await erpFetch(`/api/resource/Dispatch%20Job?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&order_by=scheduled_date+desc,start_time+asc&limit_page_length=300`) const jobs = jobsRes.status === 200 && Array.isArray(jobsRes.data?.data) ? jobsRes.data.data : [] await resolveJobLabels(jobs) const token = path.split('/')[2] res.writeHead(200, ui.htmlHeaders()) res.end(renderPage({ tech, jobs, token, today })) } catch (e) { log('tech-mobile page error:', e.message) res.writeHead(500, ui.htmlHeaders()) res.end(ui.pageError()) } } // Batch-fill customer_name + service_location_name without triggering v16's // fetched-field permission block. async function resolveJobLabels (jobs) { const custIds = [...new Set(jobs.map(j => j.customer).filter(Boolean))] const locIds = [...new Set(jobs.map(j => j.service_location).filter(Boolean))] const custNames = {}, locNames = {} if (custIds.length) { try { const f = encodeURIComponent(JSON.stringify([['name', 'in', custIds]])) const r = await erpFetch(`/api/resource/Customer?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'customer_name']))}&limit_page_length=200`) for (const c of (r.data?.data || [])) custNames[c.name] = c.customer_name || c.name } catch { /* non-fatal */ } } if (locIds.length) { try { const f = encodeURIComponent(JSON.stringify([['name', 'in', locIds]])) const r = await erpFetch(`/api/resource/Service%20Location?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'address_line_1', 'city']))}&limit_page_length=200`) for (const l of (r.data?.data || [])) locNames[l.name] = [l.address_line_1, l.city].filter(Boolean).join(', ') || l.name } catch { /* non-fatal */ } } for (const j of jobs) { j.customer_name = custNames[j.customer] || j.customer || '' j.service_location_name = locNames[j.service_location] || j.service_location || '' } } // ═════════════════════════════════════════════════════════════════════════════ // API handlers (unchanged behavior — just cleaner layout) // ═════════════════════════════════════════════════════════════════════════════ async function handleStatus (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.job || !body?.status) return json(res, 400, { error: 'job and status required' }) if (!['In Progress', 'Completed'].includes(body.status)) return json(res, 400, { error: 'Invalid status' }) try { const { setJobStatusWithChain } = require('./dispatch') const result = await setJobStatusWithChain(body.job, body.status) return json(res, 200, { ...result, tech: payload.sub }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleNote (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.job) return json(res, 400, { error: 'job required' }) try { const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(body.job)}`, { method: 'PUT', body: JSON.stringify({ notes: body.notes || '' }), }) if (r.status >= 400) return json(res, r.status, { error: r.data?.exception || r.data?._error_message || 'save failed' }) return json(res, 200, { ok: true }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handlePhotoUpload (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.job || !body?.image) return json(res, 400, { error: 'job and image required' }) try { const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '') const ext = (body.image.match(/^data:image\/([a-z]+);/) || [, 'jpg'])[1].replace('jpeg', 'jpg') const fileName = body.file_name || `dj-${body.job}-${Date.now()}.${ext}` const r = await erpFetch('/api/resource/File', { method: 'POST', body: JSON.stringify({ file_name: fileName, is_private: 1, content: base64, decode: 1, attached_to_doctype: 'Dispatch Job', attached_to_name: body.job, }), }) if (r.status >= 400) return json(res, r.status, { error: r.data?.exception || r.data?._error_message || 'upload failed' }) return json(res, 200, { ok: true, name: r.data?.data?.name, file_url: r.data?.data?.file_url, file_name: r.data?.data?.file_name }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handlePhotoList (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const url = new URL(req.url, 'http://localhost') const jobName = url.searchParams.get('job') if (!jobName) return json(res, 400, { error: 'job required' }) try { const f = encodeURIComponent(JSON.stringify([ ['attached_to_doctype', '=', 'Dispatch Job'], ['attached_to_name', '=', jobName], ['file_name', 'like', 'dj-%'], ])) const r = await erpFetch(`/api/resource/File?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'file_name', 'file_url', 'is_private', 'creation']))}&order_by=creation+desc&limit_page_length=50`) const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : [] return json(res, 200, { ok: true, items }) } catch (e) { return json(res, 500, { error: e.message }) } } // Proxy a private file so the tech browser can render without ERPNext creds. async function handlePhotoServe (req, res, path) { const payload = authToken(path) if (!payload) { res.writeHead(401); return res.end() } const url = new URL(req.url, 'http://localhost') const filePath = url.searchParams.get('p') if (!filePath || !/^\/(private\/files|files)\//.test(filePath)) { res.writeHead(400); return res.end() } try { const erpBase = (cfg.ERPNEXT_URL || 'https://erp.gigafibre.ca').replace(/\/$/, '') const headers = {} if (cfg.ERPNEXT_API_KEY && cfg.ERPNEXT_API_SECRET) { headers.Authorization = `token ${cfg.ERPNEXT_API_KEY}:${cfg.ERPNEXT_API_SECRET}` } const r = await fetch(erpBase + filePath, { headers }) if (!r.ok) { res.writeHead(404); return res.end() } const ct = r.headers.get('content-type') || 'application/octet-stream' const buf = Buffer.from(await r.arrayBuffer()) res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'private, max-age=300' }) res.end(buf) } catch (e) { log('photo-serve error:', e.message) res.writeHead(500); res.end() } } async function handleJobDetail (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const url = new URL(req.url, 'http://localhost') const name = url.searchParams.get('name') if (!name) return json(res, 400, { error: 'name required' }) try { const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(name)}`) if (r.status !== 200 || !r.data?.data) return json(res, 404, { error: 'not found' }) return json(res, 200, { ok: true, job: r.data.data }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleFieldScan (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.image || !body?.field) return json(res, 400, { error: 'image and field required' }) try { const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '') const out = await extractField(base64, body.field, { hint: body.hint, equipment_type: body.equipment_type, brand: body.brand, model: body.model, }) return json(res, 200, { ok: true, ...out }) } catch (e) { log('field-scan error:', e.message) return json(res, 500, { error: e.message }) } } async function handleScan (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.code) return json(res, 400, { error: 'code required' }) const q = body.code.replace(/[:\-\s]/g, '').toUpperCase() try { for (const field of ['serial_number', 'mac_address']) { const f = encodeURIComponent(JSON.stringify([[field, 'like', `%${q}%`]])) const r = await erpFetch(`/api/resource/Service%20Equipment?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'serial_number', 'item_name', 'equipment_type', 'mac_address', 'status', 'customer', 'service_location']))}&limit_page_length=5`) if (r.status === 200 && r.data?.data?.length) return json(res, 200, { ok: true, results: r.data.data, source: 'Service Equipment', query: q }) } const f = encodeURIComponent(JSON.stringify([['serial_no', 'like', `%${q}%`]])) const r = await erpFetch(`/api/resource/Serial%20No?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'serial_no', 'item_code', 'status', 'warehouse']))}&limit_page_length=5`) if (r.status === 200 && r.data?.data?.length) return json(res, 200, { ok: true, results: r.data.data, source: 'Serial No', query: q }) return json(res, 200, { ok: true, results: [], query: q }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleVision (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.image) return json(res, 400, { error: 'image required' }) try { const { extractBarcodes } = require('./vision') const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '') const result = await extractBarcodes(base64) return json(res, 200, { ok: true, ...result }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleEquipInstall (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body) return json(res, 400, { error: 'body required' }) const { job, barcode, mac_address, gpon_sn, equipment_type, brand, model, wifi_ssid, wifi_password, notes, customer, location, action, } = body const today = new Date().toISOString().slice(0, 10) try { const installData = { request: job || '', barcode: barcode || '', equipment_type: equipment_type || '', brand: brand || '', model: model || '', notes: [ notes || '', mac_address ? `MAC: ${mac_address}` : '', gpon_sn ? `GPON-SN: ${gpon_sn}` : '', wifi_ssid ? `SSID: ${wifi_ssid}` : '', wifi_password ? `Wi-Fi PWD: ${wifi_password}` : '', ].filter(Boolean).join(' | '), installation_date: today, technician: payload.sub, action: action || 'install', } const r = await erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify(installData) }) if (barcode && (action === 'install' || action === 'replace')) { try { const q = barcode.replace(/[:\-\s]/g, '').toUpperCase() const ef = encodeURIComponent(JSON.stringify([['serial_number', 'like', `%${q}%`]])) const existing = await erpFetch(`/api/resource/Service%20Equipment?filters=${ef}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1`) const seCommon = { status: 'Actif', customer: customer || '', service_location: location || '', mac_address: mac_address || '', gpon_sn: gpon_sn || '', wifi_ssid: wifi_ssid || '', wifi_password: wifi_password || '', brand: brand || '', model: model || '', equipment_type: equipment_type || '', } const seUpdate = Object.fromEntries(Object.entries(seCommon).filter(([, v]) => v !== '' && v != null)) if (existing.status === 200 && existing.data?.data?.length) { await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(existing.data.data[0].name)}`, { method: 'PUT', body: JSON.stringify(seUpdate), }) } else { await erpFetch('/api/resource/Service%20Equipment', { method: 'POST', body: JSON.stringify({ serial_number: barcode, ...seCommon }), }) } } catch (e) { log('Equip link/create warn:', e.message) } } return json(res, 200, { ok: true, name: r.data?.data?.name || '' }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleEquipRemove (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.equipment) return json(res, 400, { error: 'equipment name required' }) try { await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(body.equipment)}`, { method: 'PUT', body: JSON.stringify({ status: body.status || 'Retourné', customer: '', service_location: '' }), }) await erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify({ request: body.job || '', barcode: body.serial || '', equipment_type: body.equipment_type || '', notes: `Retrait par ${payload.sub}`, installation_date: new Date().toISOString().slice(0, 10), technician: payload.sub, action: 'remove', }), }) return json(res, 200, { ok: true }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleCatalog (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) try { const groups = encodeURIComponent(JSON.stringify([['item_group', 'in', ['Network Equipment', 'CPE', 'Équipements réseau', 'Products']]])) const r = await erpFetch(`/api/resource/Item?filters=${groups}&fields=${encodeURIComponent(JSON.stringify(['name', 'item_name', 'item_group', 'standard_rate', 'image', 'description']))}&limit_page_length=50`) const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : [] if (!items.length) { return json(res, 200, { ok: true, items: [ { name: 'ONT-GPON', item_name: 'ONT GPON', item_group: 'CPE', standard_rate: 0, description: 'Terminal fibre client' }, { name: 'ROUTER-WIFI6', item_name: 'Routeur Wi-Fi 6', item_group: 'CPE', standard_rate: 9.99, description: 'Routeur sans fil' }, { name: 'DECODER-IPTV', item_name: 'Décodeur IPTV', item_group: 'CPE', standard_rate: 7.99, description: 'Décodeur télévision' }, { name: 'VOIP-ATA', item_name: 'Adaptateur VoIP', item_group: 'CPE', standard_rate: 4.99, description: 'Adaptateur téléphonie' }, { name: 'AMP-COAX', item_name: 'Amplificateur coaxial', item_group: 'Network Equipment', standard_rate: 0, description: 'Amplificateur signal' }, ]}) } return json(res, 200, { ok: true, items }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleEquipList (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const url = new URL(req.url, 'http://localhost') const jobName = url.searchParams.get('job') if (!jobName) return json(res, 400, { error: 'job param required' }) try { const f = encodeURIComponent(JSON.stringify([['request', '=', jobName]])) const r = await erpFetch(`/api/resource/Equipment%20Install?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'barcode', 'equipment_type', 'brand', 'model', 'action', 'notes', 'installation_date']))}&order_by=creation+desc&limit_page_length=20`) const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : [] return json(res, 200, { ok: true, items }) } catch (e) { return json(res, 500, { error: e.message }) } } // ═════════════════════════════════════════════════════════════════════════════ // Server-side HTML rendering (uses ../ui primitives) // ═════════════════════════════════════════════════════════════════════════════ function jobCard (j, today) { const urgent = j.priority === 'urgent' || j.priority === 'high' const done = j.status === 'Completed' || j.status === 'Cancelled' const inProg = j.status === 'In Progress' || j.status === 'in_progress' const border = urgent ? 'var(--danger)' : done ? 'var(--text-dim)' : inProg ? 'var(--warning)' : 'var(--brand)' const overdue = !done && j.scheduled_date && j.scheduled_date < today const dlbl = ui.dateLabelFr(j.scheduled_date, today) const tlbl = j.start_time ? ui.fmtTime(j.start_time) : '' const datePart = [dlbl, tlbl].filter(Boolean).join(' · ') const inner = `
${ui.esc(j.name)} ${ui.badge(j.status)} ${ui.esc(datePart)}
${ui.esc(j.subject || 'Sans titre')}
${j.customer_name ? `
👤 ${ui.esc(j.customer_name)}
` : ''} ${j.service_location_name ? `
📍 ${ui.esc(j.service_location_name)}
` : ''}
${j.duration_h ? `⏱ ${j.duration_h}h` : ''} ${j.job_type ? ui.esc(j.job_type) : ''}${urgent ? ' 🔥' : ''}
` return ui.card(inner, { extraClass: (done ? 'dim ' : '') + (overdue ? 'od' : ''), onclick: `go('#job/${ui.esc(j.name)}')`, style: `border-left:4px solid ${border}`, }) } function renderPage ({ tech, jobs, token, today }) { const techName = tech.full_name || tech.name // Partition for the home view — rest is client-side filtering const inProgress = jobs.filter(j => j.status === 'In Progress' || j.status === 'in_progress') const pending = jobs.filter(j => !['Completed', 'Cancelled', 'In Progress', 'in_progress'].includes(j.status)) const overdue = pending.filter(j => j.scheduled_date && j.scheduled_date < today) const todayJobs = pending.filter(j => j.scheduled_date === today) const upcoming = pending.filter(j => j.scheduled_date && j.scheduled_date > today).slice(0, 20) const history = jobs.filter(j => j.status === 'Completed' || j.status === 'Cancelled') const nodate = pending.filter(j => !j.scheduled_date) const activeCount = inProgress.length + overdue.length + todayJobs.length + nodate.length const hub = cfg.EXTERNAL_URL || '' const jobsMap = Object.fromEntries(jobs.map(j => [j.name, j])) const body = ` ${renderHome({ techName, inProgress, overdue, todayJobs, nodate, upcoming, activeCount, historyCount: history.length, today })} ${renderHist({ history, overdue, today })} ${renderCal()} ${renderProfile({ tech, techName })}
${renderEquipOverlay()} ${ui.tabBar([ { id: 'home', label: "Aujourd'hui", icon: '📅', active: true }, { id: 'cal', label: 'Calendrier', icon: '📆' }, { id: 'hist', label: 'Historique', icon: '📋' }, { id: 'profile', label: 'Profil', icon: '👤' }, ])} ` return ui.page({ title: 'Mes tâches — Gigafibre', bodyClass: 'pad-body', cfg: { hub, base: '/t/' + token, today }, bootVars: { TOKEN: token, TODAY: today, JOBS: jobsMap, }, includeScanner: true, head: ``, body, script: CLIENT_SCRIPT, }) } // ═════════════════════════════════════════════════════════════════════════════ // View renderers (home / hist / cal / profile / overlays) // ═════════════════════════════════════════════════════════════════════════════ function renderHome ({ techName, inProgress, overdue, todayJobs, nodate, upcoming, activeCount, historyCount, today }) { const isEmpty = activeCount === 0 && inProgress.length === 0 const cards = (list) => list.map(j => jobCard(j, today)) return `
${ui.esc(ui.todayFr())}
👷${ui.esc(techName)}
${ui.statRow([ { value: activeCount, label: 'À FAIRE', id: 'stActive' }, { value: inProgress.length, label: 'EN COURS' }, { value: historyCount, label: 'TERMINÉS' }, ])}
${isEmpty ? ui.emptyState('🎉', 'Aucune tâche active.
Profitez de la pause !') : ''} ${ui.section('En cours', cards(inProgress))} ${ui.section('En retard', cards(overdue), 'danger')} ${ui.section("Aujourd'hui", cards(todayJobs))} ${ui.section('Sans date', cards(nodate))} ${ui.section('À venir', cards(upcoming))}
` } function renderHist ({ history, overdue, today }) { const all = [...overdue, ...history] const doneCount = history.filter(j => j.status === 'Completed').length const cancCount = history.filter(j => j.status === 'Cancelled').length return `
Historique
📋Tâches passées
${all.length ? all.map(j => jobCard(j, today)).join('') : ui.emptyState('📭', 'Aucun historique.')}
` } function renderCal () { return `
Calendrier
📆Vue mensuelle
${ui.placeholder('🚧', 'Bientôt disponible', `La vue calendrier avec navigation mois/semaine arrive à la phase 4.
Pour l'instant, utilisez Aujourd'hui ou Historique.`)}
` } function renderProfile ({ tech, techName }) { const line = (icon, text, link = '') => `
${icon}${ui.esc(text)}${link}
` return `
Profil
👤${ui.esc(techName)}

Informations

${line('🪪', tech.name)} ${tech.phone ? line('📞', tech.phone, `Appeler`) : ''} ${tech.email ? line('✉️', tech.email) : ''} ${tech.assigned_group ? line('👥', tech.assigned_group) : ''}

Support

${line('📞', 'Ligne support', '438-231-3838')}
${ui.button('↻ Actualiser les données', { kind: 'muted', block: true, onclick: 'location.reload()' })}
` } function renderEquipOverlay () { return `

Équipement

Installé sur cette tâche
Chargement…
Catalogue
Chargement…
` } // ═════════════════════════════════════════════════════════════════════════════ // Client-side logic (consumes window.api / window.router / window.scanner / etc.) // ═════════════════════════════════════════════════════════════════════════════ const CLIENT_SCRIPT = ` // Current detail-view job, customer, location (set by openDetail) var CJ='',CC='',CL='',CMODEL='',CTYPE=''; // Equipment overlay scanner (separate from field-scan) var stream=null, bdTimer=null; // ── Hash routes ────────────────────────────────────────────────────────── router.on('#home', function(){ showTab('home') }); router.on('#cal', function(){ showTab('cal') }); router.on('#hist', function(){ showTab('hist') }); router.on('#profile', function(){ showTab('profile') }); router.on('#job/:name', function(p){ openDetail(p.name) }); router.dispatch(); function showTab(v){ closeEquip(); if (window.scanner && window.scanner.close) scanner.close(); var views=document.querySelectorAll('.view'); for(var i=0;i=0; var okF = true; if(f==='done') okF = j.status==='Completed'; else if(f==='cancelled') okF = j.status==='Cancelled'; else if(f==='overdue') okF = j.status!=='Completed' && j.status!=='Cancelled' && j.scheduled_date && j.scheduled_date= 0; var canFinish= j.status==='In Progress' || j.status==='in_progress'; var sMeta = {Scheduled:['Planifié','#818cf8'], assigned:['Assigné','#818cf8'], open:['Ouvert','#818cf8'], 'In Progress':['En cours','#f59e0b'], in_progress:['En cours','#f59e0b'], Completed:['Terminé','#22c55e'], Cancelled:['Annulé','#94a3b8']}; var sm = sMeta[j.status] || [j.status||'—','#94a3b8']; var urgent = j.priority==='urgent' || j.priority==='high'; var addr = j.address || j.service_location_name || ''; var gps = j.service_location_name ? 'https://www.google.com/maps/dir/?api=1&destination='+encodeURIComponent(j.service_location_name) : ''; return '' +'
' + '' + '

'+_esc(j.subject||'Sans titre')+'

' + '
' + ''+sm[0]+'' + '📅 '+dlbl(j.scheduled_date,TODAY)+(j.start_time?' · '+fmtTime(j.start_time):'')+'' + (j.duration_h ? '⏱ '+j.duration_h+'h' : '') + (urgent ? '🔥 Urgent' : '') + (j.job_type ? ''+_esc(j.job_type)+'' : '') + '
' +'
' // Client & location +((j.customer_name||addr) ? ( '

Client & lieu

' +(j.customer_name ? '
👤'+_esc(j.customer_name)+'
' : '') +(addr ? '
📍'+_esc(addr)+''+(gps?'GPS':'')+'
' : '') +'
') : '') // Description +(j.description ? '

Description

'+_esc(j.description)+'
' : '') // Notes +'

Notes du technicien Éditable

' + '' + '' +'
' // Photos +'

Photos

' + '
+
' +'
' // Equipment +'

Équipement installé

' + '
Chargement…
' + '' +'
' // Action bar +(!done ? ( '
' +(gps ? '📍 Naviguer' : '') +(canStart ? '' : '') +(canFinish ? '' : '') +'
') : ''); } // ── Notes ──────────────────────────────────────────────────────────────── function saveNotes(){ var el=$('jobNotes'); if(!el) return; var b=$('saveNotesBtn'); b.disabled=true; b.textContent='Enregistrement…'; api.post('/note', {job:CJ, notes:el.value}).then(function(r){ var d=r.data||{}; if(d.ok){ toast('Notes enregistrées ✓', true); if(JOBS[CJ]) JOBS[CJ].notes=el.value } else toast('Erreur: '+(d.error||''), false); b.disabled=false; b.textContent='💾 Enregistrer les notes'; }).catch(function(){ toast('Erreur réseau', false); b.disabled=false; b.textContent='💾 Enregistrer les notes' }); } // ── Photos ─────────────────────────────────────────────────────────────── function loadPhotos(){ var el=$('photoGrid'); if(!el) return; api.get('/photos?job='+encodeURIComponent(CJ)).then(function(r){ var d=r.data||{}; var add='
+
'; if(!d.ok){ el.innerHTML=add; return } el.innerHTML = add + (d.items||[]).map(function(ph){ var src = api.url('/photo-serve?p='+encodeURIComponent(ph.file_url)); return '
'; }).join(''); }).catch(function(){}); } function onPhotoPick(input){ var f=input.files[0]; if(!f) return; input.value=''; var reader=new FileReader(); reader.onload=function(e){ toast('Envoi…', true); api.post('/photo', {job:CJ, image:e.target.result, file_name:f.name}).then(function(r){ var d=r.data||{}; if(d.ok){ toast('Photo ajoutée ✓', true); loadPhotos() } else toast('Erreur: '+(d.error||''), false); }).catch(function(){ toast('Erreur réseau', false) }); }; reader.readAsDataURL(f); } // ── Equipment overlay ──────────────────────────────────────────────────── function openEquip(job,cust,loc){ CJ=job||CJ; CC=cust||CC; CL=loc||CL; $('eqTitle').textContent='📷 '+CJ; $('eqOv').classList.add('open'); $('codeInp').value=''; $('scanRes').innerHTML=''; hideForm(); startCam(); loadEquipList(); loadCatalog(); } function closeEquip(){ var o=$('eqOv'); if(o) o.classList.remove('open'); stopCam() } function startCam(){ var v=$('vid'); if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){ v.style.display='none'; return } navigator.mediaDevices.getUserMedia({video:{facingMode:'environment',width:{ideal:1280},height:{ideal:720}}}) .then(function(s){ stream=s; v.srcObject=s; v.style.display='block'; if('BarcodeDetector' in window){ try{ var bd=new BarcodeDetector({formats:['qr_code','code_128','ean_13','ean_8','code_39']}); bdTimer=setInterval(function(){ bd.detect(v).then(function(c){ if(c.length){ $('codeInp').value=c[0].rawValue; doScan() } }).catch(function(){}); }, 500); }catch(e){} } }).catch(function(){ v.style.display='none' }); } function stopCam(){ if(bdTimer){ clearInterval(bdTimer); bdTimer=null } if(stream){ stream.getTracks().forEach(function(t){t.stop()}); stream=null } var v=$('vid'); if(v) v.srcObject=null; } function doScan(){ var code=$('codeInp').value.trim(); if(!code) return; var r=$('scanRes'); r.innerHTML='
Recherche…
'; api.post('/scan', {code:code}).then(function(x){ var d=x.data||{}; if(!d.ok){ r.innerHTML='
'+(d.error||'erreur')+'
'; return } if(!d.results.length){ r.innerHTML='
Aucun résultat pour '+_esc(code)+'. Créer nouveau →
'; $('afSn').value=code; return; } r.innerHTML=d.results.map(function(it){ var sn=it.serial_number||it.serial_no||it.name; var nm=it.item_name||it.item_code||it.equipment_type||''; var st=it.status||''; return '
'+_esc(sn)+' '+_esc(nm)+(st?' '+_esc(st)+'':'')+'
'; }).join(''); }).catch(function(){ r.innerHTML='
Erreur réseau
' }); } function loadEquipList(){ var targets=[['eqList',true],['dtEquipList',false]]; api.get('/equip-list?job='+encodeURIComponent(CJ)).then(function(r){ var d=r.data||{}; for(var i=0;iRetirer' : ''; return '
'+icon+' '+_esc(it.barcode||'—')+'
'+_esc([it.equipment_type,it.brand,it.model].filter(Boolean).join(' · '))+'
'+(it.notes?'
'+_esc(it.notes)+'
':'')+'
'+rm+'
'; }).join(''); } }).catch(function(){}); } function loadCatalog(){ var g=$('catGrid'); g.innerHTML='
Chargement…
'; api.get('/catalog').then(function(r){ var d=r.data||{}; if(!d.ok||!d.items.length){ g.innerHTML='
Catalogue vide
'; return } g.innerHTML = d.items.map(function(it){ return '
'+_esc(it.item_name||it.name)+'
'+_esc(it.item_group||'')+'
'+(it.standard_rate?'
'+Number(it.standard_rate).toFixed(2)+'$
':'')+'
'; }).join(''); }).catch(function(){ g.innerHTML='
Erreur réseau
' }); } function showForm(sn){ var f=$('addForm'); f.style.display='block'; $('afSn').value=sn||''; $('afBrand').value=''; $('afModel').value=''; $('afMac').value=''; $('afGpon').value=''; $('afSsid').value=''; $('afPwd').value=''; $('afNotes').value=''; $('afTitle').textContent='Nouvel équipement'; f.scrollIntoView({behavior:'smooth', block:'start'}); } function showFormCat(name,code,group){ showForm($('codeInp').value||''); $('afTitle').textContent=name; CMODEL=code; CTYPE=group||''; } function hideForm(){ $('addForm').style.display='none' } function submitEquip(){ var b=$('afSubmit'); b.disabled=true; b.textContent='Enregistrement…'; var data={ job:CJ, customer:CC, location:CL, barcode:$('afSn').value.trim(), equipment_type:$('afType').value, brand:$('afBrand').value.trim(), model:$('afModel').value.trim(), mac_address:$('afMac').value.trim().replace(/[:\\-\\.\\s]/g,'').toUpperCase(), gpon_sn:$('afGpon').value.trim(), wifi_ssid:$('afSsid').value.trim(), wifi_password:$('afPwd').value.trim(), notes:$('afNotes').value.trim(), action:$('afAction').value, }; api.post('/equip', data).then(function(r){ var d=r.data||{}; if(d.ok){ toast('Équipement ajouté ✓', true); hideForm(); loadEquipList() } else toast('Erreur: '+(d.error||''), false); b.disabled=false; b.textContent="Ajouter l'équipement"; }).catch(function(){ toast('Erreur réseau', false); b.disabled=false; b.textContent="Ajouter l'équipement" }); } function removeEquip(name,sn,type){ if(!confirm('Retirer cet équipement ?')) return; api.post('/equip-remove', {equipment:name, serial:sn, equipment_type:type, job:CJ}).then(function(r){ var d=r.data||{}; if(d.ok){ toast('Retiré ✓', true); loadEquipList() } else toast('Erreur', false); }).catch(function(){ toast('Erreur réseau', false) }); } (function(){ var ci=$('codeInp'); if(ci) ci.addEventListener('keydown', function(e){ if(e.key==='Enter') doScan() }) })(); // ── Field-targeted scan (Gemini, via scanner.js) ───────────────────────── function scanInto(field, label, targetId){ scanner.open(field, label, function(value){ var t=$(targetId); if(t){ t.value=value; t.dispatchEvent(new Event('input')) } toast(label+': '+value, true); }, { equipment_type: $('afType') ? $('afType').value : '', brand: $('afBrand') ? $('afBrand').value.trim() : '', model: $('afModel') ? $('afModel').value.trim() : '', }); } ` module.exports = { route }