gigafibre-fsm/services/targo-hub/lib/tech-mobile.js
louispaulb 169426a6d8 refactor(targo-hub): extract ui/ kit, migrate tech-mobile to it
Introduces services/targo-hub/lib/ui/ as the shared kit for every magic-link
page served from the hub (tech mobile, acceptance, payments):

  design.css    tokens (--brand, --success, etc) + reset + all primitives
  components.js server-side HTML builders (badge/section/card/panel/statRow/
                tabBar) + shared date helpers (fmtTime/dateLabelFr/montrealDate)
                + canonical STATUS_META
  client.js     client-side api wrapper ($, toast, api.get/post, router.on/go)
                baked into every page — no more hand-rolled fetch+hashchange
  scanner.js    Gemini field-scan overlay (window.scanner.open(field,label,cb,ctx))
  shell.js      ui.page({title, body, bootVars, cfg, script, includeScanner})
                inlines everything into one self-contained HTML doc
  index.js      barrel

Migrates tech-mobile.js to the kit:
  - drops inline esc/toast/fmtTime/dlbl/STATUS_META/badge helpers
  - api.post('/status', {...}) instead of fetch(H+'/t/'+T+'/status', {...})
  - router.on('#job/:name', handler) instead of hand-rolled route()
  - scanner.open(field, label, cb, ctx) instead of ~60 lines of field-scan logic

Behavior preserved — rendered HTML keeps tabs, detail view, notes editor,
photo upload, per-field Gemini scans, Montreal-TZ date labels, v16 link-label
resolution. Verified live at msg.gigafibre.ca with a real TECH-4 token.

Sets up acceptance.js and payments.js to drop from ~700 → ~300 lines each
in the next commits by consuming the same primitives.
2026-04-22 22:47:19 -04:00

977 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 = `
<div class="row">
<span class="jid">${ui.esc(j.name)}</span>
${ui.badge(j.status)}
<span class="jdt">${ui.esc(datePart)}</span>
</div>
<div class="jtt">${ui.esc(j.subject || 'Sans titre')}</div>
${j.customer_name ? `<div class="jsb">👤 ${ui.esc(j.customer_name)}</div>` : ''}
${j.service_location_name ? `<div class="jsb">📍 ${ui.esc(j.service_location_name)}</div>` : ''}
<div class="jmt">${j.duration_h ? `${j.duration_h}h` : ''} ${j.job_type ? ui.esc(j.job_type) : ''}${urgent ? ' 🔥' : ''}</div>`
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 })}
<div class="view" id="view-detail"></div>
${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: `<style>
.view{display:none}.view.active{display:block}
.jid{font-size:10px;font-weight:700;color:var(--brand);letter-spacing:.3px}
.jdt{margin-left:auto;font-size:11px;color:var(--text-muted);font-weight:600;text-align:right;text-transform:capitalize}
.jtt{font-size:15px;font-weight:600;margin:6px 0 2px;color:var(--text);line-height:1.25}
.jsb{font-size:12px;color:var(--text-muted);margin:1px 0}
.jmt{font-size:11px;color:var(--text-dim);margin:4px 0 0}
.sinp{width:100%;padding:10px 14px;border:1.5px solid var(--border);border-radius:12px;font-size:14px;background:var(--surface);outline:none}
.sinp:focus{border-color:var(--brand)}
.hist-filter{display:flex;gap:6px;margin-bottom:10px;overflow-x:auto;padding-bottom:4px}
.hist-filter button{padding:6px 12px;border:1.5px solid var(--border);background:var(--surface);color:var(--text-muted);border-radius:16px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;font-family:inherit}
.hist-filter button.on{border-color:var(--brand);background:var(--brand);color:#fff}
/* Detail view */
.dt-hdr{background:linear-gradient(135deg,var(--brand-dark),var(--brand));color:#fff;padding:12px 14px 24px;border-radius:0 0 18px 18px}
.dt-hdr .nav{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.dt-hdr .nav button{background:rgba(255,255,255,.18);border:none;color:#fff;width:36px;height:36px;border-radius:50%;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center}
.dt-hdr .nav .jn{flex:1;font-size:11px;font-weight:700;opacity:.8;letter-spacing:.5px}
.dt-hdr h1{font-size:19px;font-weight:700;margin-bottom:6px;line-height:1.25}
.dt-hdr .meta{display:flex;gap:8px;flex-wrap:wrap;font-size:12px;opacity:.9}
.dt-hdr .meta span{background:rgba(255,255,255,.18);padding:3px 10px;border-radius:8px}
.cust-line{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:14px;color:#334155}
.cust-line:last-child{border-bottom:none}.cust-line .ico{font-size:18px;width:24px;text-align:center}
.cust-line a{color:var(--brand);margin-left:auto;text-decoration:none;font-weight:600;font-size:13px;padding:6px 10px;background:var(--brand-soft);border-radius:8px}
.desc{font-size:14px;color:#334155;line-height:1.5;white-space:pre-wrap}
.notes-area{width:100%;min-height:120px;padding:10px 12px;border:1.5px solid var(--border);border-radius:10px;font-size:14px;font-family:inherit;resize:vertical;outline:none;line-height:1.4}
.notes-area:focus{border-color:var(--brand)}
.photo-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:4px}
.photo-grid .ph{position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--border-soft)}
.photo-grid .ph img{width:100%;height:100%;object-fit:cover;display:block}
.photo-add{aspect-ratio:1;border:2px dashed #cbd5e1;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:26px;color:var(--text-dim);cursor:pointer;background:var(--surface-alt)}
.photo-add:active{background:var(--brand-soft);border-color:var(--brand);color:var(--brand)}
.eq-item{background:var(--surface-alt);border-radius:10px;padding:10px 12px;margin-bottom:6px;display:flex;align-items:center;gap:10px}
.eq-item .eq-info{flex:1}.eq-sn{font-weight:700;color:var(--text);font-size:13px}.eq-type{font-size:11px;color:var(--text-muted);margin-top:2px}
.eq-rm{background:var(--danger-soft);color:var(--danger);border:none;border-radius:6px;padding:6px 10px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit}
.add-eq-btn{width:100%;padding:14px;background:var(--brand-soft);color:var(--brand);border:2px dashed #c7d2fe;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;margin-top:6px;font-family:inherit}
.add-eq-btn:active{background:#e0e7ff}
.action-bar{position:sticky;bottom:68px;left:0;right:0;background:var(--surface);border-top:1px solid var(--border);padding:10px 12px;display:flex;gap:8px;z-index:30;margin:20px -12px 0}
.action-bar .btn{flex:1;padding:14px}
/* Equipment add form */
.af{background:var(--surface);border-radius:12px;padding:14px;margin-bottom:16px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.af-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.af-row b{font-size:15px;color:var(--text)}
.af label{display:block;font-size:11px;font-weight:700;color:#475569;margin:10px 0 4px;letter-spacing:.3px;text-transform:uppercase}
.af .fwrap{position:relative;display:flex;gap:6px;align-items:stretch}
.af .fwrap input,.af .fwrap select{flex:1;padding:9px 11px;border:1.5px solid var(--border);border-radius:8px;font-size:14px;background:var(--surface);outline:none;font-family:inherit}
.af .fwrap input:focus,.af .fwrap select:focus{border-color:var(--brand)}
.af .fscan{width:42px;height:auto;border:none;border-radius:8px;background:var(--brand-soft);color:var(--brand);font-size:18px;cursor:pointer;flex-shrink:0}
.af .fscan:active{background:#e0e7ff}
.cat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
.cat-card{background:var(--surface);border:2px solid #e5e7eb;border-radius:10px;padding:12px;text-align:center;cursor:pointer;transition:all var(--t-fast)}
.cat-card:active{border-color:var(--brand);background:var(--brand-soft)}
.cat-card .cn{font-size:13px;font-weight:700;color:#1e293b;margin-bottom:2px}
.cat-card .cd{font-size:10px;color:var(--text-dim)}
.cat-card .cp{font-size:13px;font-weight:700;color:var(--brand);margin-top:4px}
.inp-row{display:flex;gap:8px;margin-bottom:10px}
.inp-row .inp{flex:1}
</style>`,
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 `<div class="view active" id="view-home">
<div class="hdr">
<div class="hdr-d">${ui.esc(ui.todayFr())}</div>
<div class="hdr-n"><span class="ic">👷</span>${ui.esc(techName)}</div>
${ui.statRow([
{ value: activeCount, label: 'À FAIRE', id: 'stActive' },
{ value: inProgress.length, label: 'EN COURS' },
{ value: historyCount, label: 'TERMINÉS' },
])}
</div>
<div class="wrap">
${isEmpty ? ui.emptyState('🎉', 'Aucune tâche active.<br/>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))}
</div>
</div>`
}
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 `<div class="view" id="view-hist">
<div class="hdr">
<div class="hdr-d">Historique</div>
<div class="hdr-n"><span class="ic">📋</span>Tâches passées</div>
</div>
<div class="wrap">
<div class="sbar"><input class="sinp" id="histSearch" placeholder="🔍 Rechercher (client, lieu, sujet…)"></div>
<div class="hist-filter" id="histFilter">
<button class="on" data-f="all">Tous (${all.length})</button>
<button data-f="done">Terminés (${doneCount})</button>
<button data-f="overdue">Manqués (${overdue.length})</button>
<button data-f="cancelled">Annulés (${cancCount})</button>
</div>
<div id="histList">
${all.length ? all.map(j => jobCard(j, today)).join('') : ui.emptyState('📭', 'Aucun historique.')}
</div>
</div>
</div>`
}
function renderCal () {
return `<div class="view" id="view-cal">
<div class="hdr">
<div class="hdr-d">Calendrier</div>
<div class="hdr-n"><span class="ic">📆</span>Vue mensuelle</div>
</div>
<div class="wrap">
${ui.placeholder('🚧', 'Bientôt disponible', `La vue calendrier avec navigation mois/semaine arrive à la phase 4.<br/>Pour l'instant, utilisez <b>Aujourd'hui</b> ou <b>Historique</b>.`)}
</div>
</div>`
}
function renderProfile ({ tech, techName }) {
const line = (icon, text, link = '') =>
`<div class="cust-line"><span class="ico">${icon}</span><span>${ui.esc(text)}</span>${link}</div>`
return `<div class="view" id="view-profile">
<div class="hdr">
<div class="hdr-d">Profil</div>
<div class="hdr-n"><span class="ic">👤</span>${ui.esc(techName)}</div>
</div>
<div class="wrap">
<div class="panel">
<h3>Informations</h3>
${line('🪪', tech.name)}
${tech.phone ? line('📞', tech.phone, `<a href="tel:${ui.esc(tech.phone)}">Appeler</a>`) : ''}
${tech.email ? line('✉️', tech.email) : ''}
${tech.assigned_group ? line('👥', tech.assigned_group) : ''}
</div>
<div class="panel">
<h3>Support</h3>
${line('📞', 'Ligne support', '<a href="tel:4382313838">438-231-3838</a>')}
</div>
<div class="panel ta-c" style="padding:20px">
${ui.button('↻ Actualiser les données', { kind: 'muted', block: true, onclick: 'location.reload()' })}
</div>
</div>
</div>`
}
function renderEquipOverlay () {
return `<div class="ov" id="eqOv">
<div class="ov-hdr">
<button class="ov-x" onclick="closeEquip()">✕</button>
<h2 id="eqTitle">Équipement</h2>
</div>
<div class="ov-body">
<div class="cam-wrap"><video id="vid" autoplay playsinline muted></video><div class="cam-overlay"></div></div>
<div class="inp-row">
<input class="inp" id="codeInp" placeholder="SN, MAC ou code-barre…" autocomplete="off" autocapitalize="off">
<button class="btn btn-pri btn-sm" onclick="doScan()">Chercher</button>
</div>
<div id="scanRes"></div>
<div class="ov-sec">Installé sur cette tâche</div>
<div id="eqList"><div class="sr sm">Chargement…</div></div>
<div class="ov-sec">Catalogue</div>
<div class="cat-grid" id="catGrid"><div class="sr sm">Chargement…</div></div>
<div class="af" id="addForm" style="display:none">
<div class="af-row"><b id="afTitle">Nouvel équipement</b><button class="ov-x" onclick="hideForm()">✕</button></div>
<label>Type</label>
<div class="fwrap"><select id="afType"><option>ONT</option><option>Routeur</option><option>Modem</option><option>Décodeur TV</option><option>VoIP</option><option>Switch</option><option>AP</option><option>Amplificateur</option><option>Splitter</option><option>Autre</option></select></div>
<label>Marque</label> <div class="fwrap"><input id="afBrand" placeholder="Ex: TP-Link, Huawei, ZTE…"></div>
<label>Modèle</label> <div class="fwrap"><input id="afModel" placeholder="Ex: HG8245H"><button class="fscan" onclick="scanInto('model','Modèle','afModel')">📷</button></div>
<label>Numéro de série</label><div class="fwrap"><input id="afSn" placeholder="SN / Serial"><button class="fscan" onclick="scanInto('serial_number','Numéro de série','afSn')">📷</button></div>
<label>Adresse MAC</label> <div class="fwrap"><input id="afMac" placeholder="AA:BB:CC:DD:EE:FF"><button class="fscan" onclick="scanInto('mac_address','Adresse MAC','afMac')">📷</button></div>
<label>GPON SN</label> <div class="fwrap"><input id="afGpon" placeholder="HWTC12345678"><button class="fscan" onclick="scanInto('gpon_sn','GPON SN','afGpon')">📷</button></div>
<label>Wi-Fi SSID</label> <div class="fwrap"><input id="afSsid" placeholder="Nom du réseau Wi-Fi"><button class="fscan" onclick="scanInto('wifi_ssid','SSID Wi-Fi','afSsid')">📷</button></div>
<label>Wi-Fi Password</label><div class="fwrap"><input id="afPwd" placeholder="Clé WPA"><button class="fscan" onclick="scanInto('wifi_password','Mot de passe Wi-Fi','afPwd')">📷</button></div>
<label>Notes</label> <div class="fwrap"><input id="afNotes" placeholder="Port, emplacement…"></div>
<label>Action</label> <div class="fwrap"><select id="afAction"><option value="install">Installer</option><option value="replace">Remplacer</option></select></div>
<button class="btn btn-pri btn-block mt-2" id="afSubmit" onclick="submitEquip()">Ajouter l'équipement</button>
</div>
</div>
</div>`
}
// ═════════════════════════════════════════════════════════════════════════════
// 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<views.length;i++) views[i].classList.remove('active');
$('view-'+v).classList.add('active');
var tabs=document.querySelectorAll('.tab');
for(var k=0;k<tabs.length;k++) tabs[k].classList.toggle('on', tabs[k].dataset.v===v);
}
// ── Status update (start / finish) ───────────────────────────────────────
function doSt(jobName, status, btn){
if(btn){btn.classList.add('dis');btn.textContent='…'}
api.post('/status', {job:jobName, status:status}).then(function(r){
var d=r.data||{};
if(d.ok||d.status==='In Progress'||d.status==='Completed'){
toast(status==='Completed' ? 'Terminé ✓' : 'En route ▶', true);
if(JOBS[jobName]) JOBS[jobName].status=status;
setTimeout(function(){location.reload()}, 700);
} else {
toast('Erreur: '+(d.error||''), false);
if(btn) btn.classList.remove('dis');
}
}).catch(function(){ toast('Erreur réseau', false); if(btn) btn.classList.remove('dis') });
}
// ── Historique search + filter ───────────────────────────────────────────
(function(){
var s=$('histSearch'); if(s) s.addEventListener('input', applyHistFilter);
var hf=document.querySelectorAll('#histFilter button');
for(var i=0;i<hf.length;i++){
hf[i].addEventListener('click', function(e){
for(var j=0;j<hf.length;j++) hf[j].classList.remove('on');
e.target.classList.add('on'); applyHistFilter();
});
}
})();
function applyHistFilter(){
var qEl=$('histSearch'); var q = qEl ? qEl.value.toLowerCase().trim() : '';
var fbtn=document.querySelector('#histFilter button.on');
var f = fbtn ? fbtn.dataset.f : 'all';
var cards=document.querySelectorAll('#histList .card');
for(var i=0;i<cards.length;i++){
var c=cards[i]; var txt=c.textContent.toLowerCase();
var jidEl=c.querySelector('.jid'); if(!jidEl){c.style.display='none';continue}
var j=JOBS[jidEl.textContent]; if(!j){c.style.display='none';continue}
var okQ = !q || txt.indexOf(q)>=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<TODAY;
c.style.display = (okQ&&okF) ? 'block' : 'none';
}
}
// ── Job detail view ──────────────────────────────────────────────────────
function openDetail(name){
var j=JOBS[name];
if(!j){ toast('Tâche introuvable', false); go('#home'); return }
CJ=j.name; CC=j.customer||''; CL=j.service_location||'';
var views=document.querySelectorAll('.view');
for(var i=0;i<views.length;i++) views[i].classList.remove('active');
$('view-detail').classList.add('active');
$('view-detail').innerHTML=renderDetail(j);
loadPhotos(); loadEquipList();
}
function renderDetail(j){
var done = j.status==='Completed' || j.status==='Cancelled';
var canStart = ['Scheduled','assigned','open'].indexOf(j.status) >= 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 ''
+'<div class="dt-hdr">'
+ '<div class="nav"><button onclick="go(\\'#home\\')"></button><div class="jn">'+_esc(j.name)+'</div></div>'
+ '<h1>'+_esc(j.subject||'Sans titre')+'</h1>'
+ '<div class="meta">'
+ '<span style="background:'+sm[1]+'">'+sm[0]+'</span>'
+ '<span>📅 '+dlbl(j.scheduled_date,TODAY)+(j.start_time?' · '+fmtTime(j.start_time):'')+'</span>'
+ (j.duration_h ? '<span>⏱ '+j.duration_h+'h</span>' : '')
+ (urgent ? '<span style="background:#ef4444">🔥 Urgent</span>' : '')
+ (j.job_type ? '<span>'+_esc(j.job_type)+'</span>' : '')
+ '</div>'
+'</div>'
// Client & location
+((j.customer_name||addr) ? (
'<div class="panel"><h3>Client & lieu</h3>'
+(j.customer_name ? '<div class="cust-line"><span class="ico">👤</span><span>'+_esc(j.customer_name)+'</span></div>' : '')
+(addr ? '<div class="cust-line"><span class="ico">📍</span><span>'+_esc(addr)+'</span>'+(gps?'<a href="'+gps+'" target="_blank">GPS</a>':'')+'</div>' : '')
+'</div>') : '')
// Description
+(j.description ? '<div class="panel"><h3>Description</h3><div class="desc">'+_esc(j.description)+'</div></div>' : '')
// Notes
+'<div class="panel"><h3>Notes du technicien <span class="rt">Éditable</span></h3>'
+ '<textarea class="notes-area" id="jobNotes" placeholder="Observations, codes, contacts…">'+_esc(j.notes||'')+'</textarea>'
+ '<button class="btn btn-pri btn-block mt-1" id="saveNotesBtn" onclick="saveNotes()">💾 Enregistrer les notes</button>'
+'</div>'
// Photos
+'<div class="panel"><h3>Photos <span class="rt"><input type="file" id="photoInp" accept="image/*" capture="environment" style="display:none" onchange="onPhotoPick(this)"></span></h3>'
+ '<div class="photo-grid" id="photoGrid"><div class="photo-add" onclick="$(\\'photoInp\\').click()">+</div></div>'
+'</div>'
// Equipment
+'<div class="panel"><h3>Équipement installé</h3>'
+ '<div id="dtEquipList"><div class="sr sm">Chargement…</div></div>'
+ '<button class="add-eq-btn" onclick="openEquip(CJ,CC,CL)">📷 Ajouter / scanner un équipement</button>'
+'</div>'
// Action bar
+(!done ? (
'<div class="action-bar">'
+(gps ? '<a href="'+gps+'" target="_blank" class="btn btn-ghost">📍 Naviguer</a>' : '')
+(canStart ? '<button class="btn btn-pri" onclick="doSt(\\''+j.name+'\\',\\'In Progress\\',this)">▶ Démarrer</button>' : '')
+(canFinish ? '<button class="btn btn-ok" onclick="doSt(\\''+j.name+'\\',\\'Completed\\',this)">✓ Terminer</button>' : '')
+'</div>') : '');
}
// ── 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='<div class="photo-add" onclick="$(\\'photoInp\\').click()">+</div>';
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 '<div class="ph"><a href="'+src+'" target="_blank"><img src="'+src+'" loading="lazy"/></a></div>';
}).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='<div class="sr sm">Recherche…</div>';
api.post('/scan', {code:code}).then(function(x){
var d=x.data||{};
if(!d.ok){ r.innerHTML='<div class="sr" style="color:#ef4444">'+(d.error||'erreur')+'</div>'; return }
if(!d.results.length){
r.innerHTML='<div class="sr">Aucun résultat pour <b>'+_esc(code)+'</b>. <a href="#" onclick="showForm(&quot;'+_esc(code)+'&quot;);return false" style="color:#5c59a8;font-weight:600;margin-left:6px">Créer nouveau →</a></div>';
$('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 '<div class="sr"><b>'+_esc(sn)+'</b> '+_esc(nm)+(st?' <span class="bdg" style="background:#818cf820;color:#818cf8">'+_esc(st)+'</span>':'')+'</div>';
}).join('');
}).catch(function(){ r.innerHTML='<div class="sr" style="color:#ef4444">Erreur réseau</div>' });
}
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;i<targets.length;i++){
var el=$(targets[i][0]); if(!el) continue;
if(!d.ok||!d.items.length){ el.innerHTML='<div class="sr sm">Aucun équipement installé</div>'; continue }
el.innerHTML = d.items.map(function(it){
var icon = it.action==='remove' ? '🔴' : '🟢';
var rm = (it.action!=='remove' && targets[i][1]) ? '<button class="eq-rm" onclick="removeEquip(&quot;'+_esc(it.name)+'&quot;,&quot;'+_esc(it.barcode||'')+'&quot;,&quot;'+_esc(it.equipment_type||'')+'&quot;)">Retirer</button>' : '';
return '<div class="eq-item"><div class="eq-info"><div class="eq-sn">'+icon+' '+_esc(it.barcode||'—')+'</div><div class="eq-type">'+_esc([it.equipment_type,it.brand,it.model].filter(Boolean).join(' · '))+'</div>'+(it.notes?'<div class="eq-type" style="margin-top:4px;color:#475569">'+_esc(it.notes)+'</div>':'')+'</div>'+rm+'</div>';
}).join('');
}
}).catch(function(){});
}
function loadCatalog(){
var g=$('catGrid'); g.innerHTML='<div class="sr sm">Chargement…</div>';
api.get('/catalog').then(function(r){
var d=r.data||{};
if(!d.ok||!d.items.length){ g.innerHTML='<div class="sr sm">Catalogue vide</div>'; return }
g.innerHTML = d.items.map(function(it){
return '<div class="cat-card" onclick="showFormCat(&quot;'+_esc(it.item_name||it.name)+'&quot;,&quot;'+_esc(it.name)+'&quot;,&quot;'+_esc(it.item_group||'')+'&quot;)"><div class="cn">'+_esc(it.item_name||it.name)+'</div><div class="cd">'+_esc(it.item_group||'')+'</div>'+(it.standard_rate?'<div class="cp">'+Number(it.standard_rate).toFixed(2)+'$</div>':'')+'</div>';
}).join('');
}).catch(function(){ g.innerHTML='<div class="sr sm">Erreur réseau</div>' });
}
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 }