gigafibre-fsm/services/targo-hub/lib/tech-mobile.js
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00

563 lines
34 KiB
JavaScript

'use strict'
const cfg = require('./config')
const { log, json, erpFetch } = require('./helpers')
const { verifyJwt } = require('./magic-link')
// ── Lightweight server-rendered mobile page for field techs ───────────────
// No framework, inline CSS+JS, <15KB HTML. Scanner uses native BarcodeDetector
// + fallback photo→Gemini Vision. Equipment CRUD via targo-hub proxy to ERPNext.
// ── Auth helper ──────────────────────────────────────────────────────────────
function authToken (path) {
const m = path.match(/^\/t\/([A-Za-z0-9_\-\.]+)/)
if (!m) return null
return verifyJwt(m[1])
}
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) {
// GET /t/{token} → page
if (method === 'GET' && /^\/t\/[A-Za-z0-9_\-\.]+$/.test(path)) return handlePage(req, res, path)
// POST /t/{token}/status → job status update
if (method === 'POST' && path.endsWith('/status')) return handleStatus(req, res, path)
// POST /t/{token}/scan → barcode/serial lookup
if (method === 'POST' && path.endsWith('/scan')) return handleScan(req, res, path)
// POST /t/{token}/vision → photo → Gemini AI equipment label read
if (method === 'POST' && path.endsWith('/vision')) return handleVision(req, res, path)
// POST /t/{token}/equip → create Equipment Install
if (method === 'POST' && path.endsWith('/equip')) return handleEquipInstall(req, res, path)
// POST /t/{token}/equip-remove → remove/return equipment
if (method === 'POST' && path.endsWith('/equip-remove')) return handleEquipRemove(req, res, path)
// GET /t/{token}/catalog → equipment catalog items
if (method === 'GET' && path.endsWith('/catalog')) return handleCatalog(req, res, path)
// GET /t/{token}/equip-list → equipment installed on a job
if (method === 'GET' && path.endsWith('/equip-list')) return handleEquipList(req, res, path)
return json(res, 404, { error: 'Not found' })
}
// ── GET /t/{token} — Server-rendered page ────────────────────────────────────
async function handlePage (req, res, path) {
const payload = authToken(path)
if (!payload) { res.writeHead(200, html()); return res.end(pageExpired()) }
const techId = payload.sub
const today = new Date().toISOString().slice(0, 10)
try {
const techRes = await erpFetch(`/api/resource/Dispatch%20Technician/${encodeURIComponent(techId)}?fields=["name","full_name","phone"]`)
const techName = techRes.status === 200 && techRes.data?.data?.full_name ? techRes.data.data.full_name : techId
const fields = '["name","subject","status","customer","customer_name","service_location","service_location_name","scheduled_time","scheduled_date","duration_h","priority","description","job_type"]'
const filters = encodeURIComponent(JSON.stringify([['assigned_tech', '=', techId], ['scheduled_date', '=', today]]))
const jobsRes = await erpFetch(`/api/resource/Dispatch%20Job?fields=${fields}&filters=${filters}&order_by=scheduled_time+asc&limit_page_length=50`)
const jobs = jobsRes.status === 200 && Array.isArray(jobsRes.data?.data) ? jobsRes.data.data : []
res.writeHead(200, html())
res.end(renderPage(techName, jobs, path.split('/')[2]))
} catch (e) {
log('tech-mobile error:', e.message)
res.writeHead(500, html())
res.end(pageError())
}
}
// ── POST /t/{token}/status — Update job status ──────────────────────────────
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' })
const allowed = ['In Progress', 'Completed']
if (!allowed.includes(body.status)) return json(res, 400, { error: 'Invalid status' })
try {
const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(body.job)}`, {
method: 'PUT', body: JSON.stringify({ status: body.status }),
})
if (r.status >= 400) return json(res, r.status, { error: 'ERPNext error' })
require('./sse').broadcast('dispatch', 'job-status', { job: body.job, status: body.status, tech: payload.sub })
return json(res, 200, { ok: true })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// ── POST /t/{token}/scan — Barcode/serial lookup ────────────────────────────
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 {
// Search Service Equipment
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=["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 })
}
// Search Serial No
const f = encodeURIComponent(JSON.stringify([['serial_no', 'like', `%${q}%`]]))
const r = await erpFetch(`/api/resource/Serial%20No?filters=${f}&fields=["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 }) }
}
// ── POST /t/{token}/vision — Photo → Gemini AI label read ───────────────────
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' })
// Proxy to existing vision.js extractBarcodes + equipment logic
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 }) }
}
// ── POST /t/{token}/equip — Create Equipment Install ────────────────────────
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, equipment_type, brand, model, notes, customer, location, action } = body
const today = new Date().toISOString().slice(0, 10)
try {
// 1. Create Equipment Install record
const installData = {
request: job || '',
barcode: barcode || '',
equipment_type: equipment_type || '',
brand: brand || '',
model: model || '',
notes: notes || '',
installation_date: today,
technician: payload.sub,
action: action || 'install', // install | replace | remove
}
const r = await erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify(installData) })
// 2. Try to link/create Service Equipment record
if (barcode && (action === 'install' || action === 'replace')) {
try {
// Check if equipment exists
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=["name"]&limit_page_length=1`)
if (existing.status === 200 && existing.data?.data?.length) {
// Update existing: link to customer/location
await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(existing.data.data[0].name)}`, {
method: 'PUT',
body: JSON.stringify({
status: 'Actif',
customer: customer || '',
service_location: location || '',
}),
})
} else {
// Create new
await erpFetch('/api/resource/Service%20Equipment', {
method: 'POST',
body: JSON.stringify({
serial_number: barcode,
equipment_type: equipment_type || 'ONT',
brand: brand || '',
model: model || '',
status: 'Actif',
customer: customer || '',
service_location: location || '',
}),
})
}
} 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 }) }
}
// ── POST /t/{token}/equip-remove — Remove/return equipment ──────────────────
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: '',
}),
})
// Log as Equipment Install with action=remove
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 }) }
}
// ── GET /t/{token}/catalog — Equipment catalog ──────────────────────────────
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=["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 : []
// Fallback hardcoded if API returns nothing
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 }) }
}
// ── GET /t/{token}/equip-list?job=X — Equipment on a job ────────────────────
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=["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 }) }
}
// ═════════════════════════════════════════════════════════════════════════════
// HTML RENDERING
// ═════════════════════════════════════════════════════════════════════════════
function html () { return { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store' } }
function esc (s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
function fmtTime (t) { if (!t) return ''; const [h, m] = t.split(':'); return `${h}h${m}` }
function todayFr () { return new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' }) }
function badge (s) {
const map = { Scheduled: ['À faire', '#818cf8'], assigned: ['Assigné', '#818cf8'], open: ['Ouvert', '#818cf8'], 'In Progress': ['En cours', '#f59e0b'], in_progress: ['En cours', '#f59e0b'], Completed: ['Terminé', '#22c55e'], Cancelled: ['Annulé', '#ef4444'] }
const [l, c] = map[s] || [s, '#94a3b8']
return `<span class="bdg" style="background:${c}20;color:${c}">${esc(l)}</span>`
}
function jobCard (j) {
const urgent = j.priority === 'urgent' || j.priority === 'high'
const border = urgent ? '#ef4444' : j.status === 'Completed' ? '#22c55e' : '#5c59a8'
const canStart = ['Scheduled', 'assigned', 'open'].includes(j.status)
const canFinish = ['In Progress', 'in_progress'].includes(j.status)
const done = j.status === 'Completed'
const loc = j.service_location_name || ''
const gps = loc ? `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(loc)}` : ''
return `<div class="card${done ? ' dim' : ''}" style="border-left:4px solid ${border}">
<div class="row"><span class="jid">${esc(j.name)}</span>${badge(j.status)}<span class="jtm">${fmtTime(j.scheduled_time)}</span></div>
<div class="jtt">${esc(j.subject || 'Sans titre')}</div>
${j.customer_name ? `<div class="jsb">${esc(j.customer_name)}</div>` : ''}
${loc ? `<div class="jsb">📍 ${esc(loc)}</div>` : ''}
<div class="jmt">${j.duration_h ? `${j.duration_h}h` : ''} ${j.job_type ? esc(j.job_type) : ''}</div>
<div class="acts">
${gps ? `<a href="${gps}" target="_blank" class="btn bn">📍 Naviguer</a>` : ''}
${!done ? `<button class="btn bs" onclick="openEquip('${esc(j.name)}','${esc(j.customer || '')}','${esc(j.service_location || '')}')">📷 Équipement</button>` : ''}
${canStart ? `<button class="btn bp" onclick="doSt('${esc(j.name)}','In Progress',this)">▶ En route</button>` : ''}
${canFinish ? `<button class="btn bg" onclick="doSt('${esc(j.name)}','Completed',this)">✓ Terminer</button>` : ''}
</div></div>`
}
function renderPage (techName, jobs, token) {
const todo = jobs.filter(j => j.status !== 'Completed' && j.status !== 'Cancelled')
const ip = jobs.filter(j => j.status === 'In Progress' || j.status === 'in_progress')
const up = jobs.filter(j => ['Scheduled', 'assigned', 'open'].includes(j.status))
const done = jobs.filter(j => j.status === 'Completed')
const hub = cfg.EXTERNAL_URL || ''
return `<!DOCTYPE html><html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#3f3d7a"><title>Mes tâches — Gigafibre</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#eef1f5;color:#1a1a2e;-webkit-text-size-adjust:100%}
.hdr{background:linear-gradient(135deg,#3f3d7a,#5c59a8);color:#fff;padding:16px 16px 36px;border-radius:0 0 20px 20px}
.hdr-d{font-size:13px;opacity:.7;text-transform:capitalize}.hdr-n{font-size:20px;font-weight:700;margin:2px 0 12px}
.sts{display:flex;gap:8px}.st{flex:1;background:rgba(255,255,255,.15);border-radius:10px;padding:10px 4px;text-align:center}
.st b{display:block;font-size:20px}.st small{font-size:10px;opacity:.7}
.wrap{padding:12px 12px 90px;margin-top:-18px}
.sec{font-size:11px;font-weight:700;color:#5c59a8;letter-spacing:1px;margin:18px 0 8px;text-transform:uppercase}
.card{background:#fff;border-radius:12px;padding:12px;margin-bottom:10px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.dim{opacity:.55}
.row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.jid{font-size:11px;font-weight:700;color:#5c59a8}.jtm{margin-left:auto;font-size:12px;color:#64748b;font-weight:600}
.bdg{padding:2px 8px;border-radius:8px;font-size:11px;font-weight:600}
.jtt{font-size:15px;font-weight:600;margin:6px 0 2px}.jsb{font-size:12px;color:#64748b;margin:1px 0}
.jmt{font-size:11px;color:#94a3b8;margin:4px 0 0}
.acts{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;gap:4px;padding:8px 12px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;text-decoration:none;transition:opacity .15s}
.btn:active{opacity:.7}
.bn{background:#eef2ff;color:#4f46e5}.bs{background:#f0fdf4;color:#16a34a}
.bp{background:#5c59a8;color:#fff}.bg{background:#22c55e;color:#fff}
.dis{opacity:.5;pointer-events:none}
.empty{text-align:center;padding:40px 16px;color:#94a3b8}
.toast{position:fixed;top:12px;left:12px;right:12px;padding:12px;border-radius:10px;color:#fff;font-size:13px;font-weight:600;text-align:center;z-index:200;opacity:0;transition:opacity .3s;pointer-events:none}
.toast.on{opacity:1}
.fabs{position:fixed;bottom:16px;right:12px;display:flex;gap:10px;z-index:50}
.fab{width:48px;height:48px;border-radius:50%;border:none;font-size:20px;box-shadow:0 2px 8px rgba(0,0,0,.2);cursor:pointer;display:flex;align-items:center;justify-content:center}
.fab:active{transform:scale(.92)}
/* ── Equipment overlay ── */
.ov{display:none;position:fixed;inset:0;background:#f3f4f6;z-index:100;flex-direction:column;overflow-y:auto}
.ov.open{display:flex}
.ov-hdr{display:flex;align-items:center;padding:12px 16px;background:#fff;border-bottom:1px solid #e5e7eb;position:sticky;top:0;z-index:2}
.ov-hdr h2{font-size:16px;font-weight:700;flex:1;margin:0}.ov-x{background:none;border:none;font-size:22px;cursor:pointer;padding:4px 8px;color:#64748b}
.ov-body{padding:16px;flex:1}
.ov-sec{font-size:11px;font-weight:700;color:#5c59a8;letter-spacing:1px;margin:16px 0 8px;text-transform:uppercase}
/* Scanner area */
.cam-wrap{position:relative;width:100%;max-width:400px;margin:0 auto 12px;border-radius:12px;overflow:hidden;background:#000}
.cam-wrap video{width:100%;display:block}
.inp-row{display:flex;gap:8px;margin-bottom:12px}
.inp{flex:1;padding:10px 12px;border:2px solid #d1d5db;border-radius:8px;font-size:15px;background:#fff}
.inp:focus{border-color:#5c59a8;outline:none}
.btn-sm{padding:10px 16px;border:none;border-radius:8px;background:#5c59a8;color:#fff;font-weight:600;font-size:14px;cursor:pointer}
/* Scan results */
.sr{background:#fff;border-radius:10px;padding:10px 12px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.06)}
.sr b{color:#5c59a8}.sr .sm{font-size:11px;color:#94a3b8}
/* Installed list */
.eq-item{background:#fff;border-radius:10px;padding:10px 12px;margin-bottom:6px;display:flex;align-items:center;gap:10px;box-shadow:0 1px 2px rgba(0,0,0,.06)}
.eq-item .eq-info{flex:1}.eq-item .eq-sn{font-weight:700;color:#1e293b;font-size:13px}.eq-item .eq-type{font-size:11px;color:#64748b}
.eq-rm{background:#fef2f2;color:#ef4444;border:none;border-radius:6px;padding:6px 10px;font-size:12px;font-weight:600;cursor:pointer}
/* Catalog */
.cat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
.cat-card{background:#fff;border:2px solid #e5e7eb;border-radius:10px;padding:12px;text-align:center;cursor:pointer;transition:border-color .15s}
.cat-card:active{border-color:#5c59a8}.cat-card .cn{font-size:13px;font-weight:700;color:#1e293b;margin-bottom:2px}
.cat-card .cd{font-size:10px;color:#94a3b8}.cat-card .cp{font-size:14px;font-weight:700;color:#5c59a8;margin-top:4px}
/* Add form */
.af{background:#fff;border-radius:12px;padding:16px;margin-bottom:16px}
.af label{display:block;font-size:12px;font-weight:600;color:#374151;margin:8px 0 4px}
.af select,.af input{width:100%;padding:8px 10px;border:1.5px solid #d1d5db;border-radius:6px;font-size:14px;background:#fff}
.af select:focus,.af input:focus{border-color:#5c59a8;outline:none}
.photo-row{display:flex;align-items:center;gap:8px;margin:8px 0}
.photo-btn{background:#eef2ff;color:#4f46e5;border:none;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;cursor:pointer}
.photo-thumb{width:48px;height:48px;border-radius:8px;object-fit:cover}
.btn-add-full{width:100%;padding:12px;border:none;border-radius:10px;background:#5c59a8;color:#fff;font-size:15px;font-weight:700;cursor:pointer;margin-top:12px}
.btn-add-full:active{background:#3f3d7a}.btn-add-full:disabled{opacity:.5}
</style></head><body>
<div class="hdr">
<div class="hdr-d">${esc(todayFr())}</div>
<div class="hdr-n">${esc(techName)}</div>
<div class="sts">
<div class="st"><b>${jobs.length}</b><small>Total</small></div>
<div class="st"><b>${todo.length}</b><small>À faire</small></div>
<div class="st"><b>${done.length}</b><small>Faits</small></div>
</div>
</div>
<div class="wrap">
${jobs.length === 0 ? '<div class="empty">Aucune tâche aujourd\'hui 🎉</div>' : ''}
${ip.length ? `<div class="sec">En cours (${ip.length})</div>${ip.map(j => jobCard(j)).join('')}` : ''}
${up.length ? `<div class="sec">À venir (${up.length})</div>${up.map(j => jobCard(j)).join('')}` : ''}
${done.length ? `<div class="sec">Terminés (${done.length})</div>${done.map(j => jobCard(j)).join('')}` : ''}
</div>
<!-- ═══ Equipment overlay ═══ -->
<div class="ov" id="eqOv">
<div class="ov-hdr"><h2 id="eqTitle">📷 Équipement</h2><button class="ov-x" onclick="closeEquip()">✕</button></div>
<div class="ov-body">
<!-- Camera -->
<div class="cam-wrap"><video id="vid" autoplay playsinline muted></video></div>
<div class="inp-row">
<input class="inp" id="codeInp" placeholder="SN, MAC ou code-barre…" autocomplete="off" autocapitalize="off">
<button class="btn-sm" onclick="doScan()">Chercher</button>
</div>
<!-- Scan results -->
<div id="scanRes"></div>
<!-- Installed on this job -->
<div class="ov-sec">Équipement installé</div>
<div id="eqList"><div class="sr sm">Chargement…</div></div>
<!-- Catalogue -->
<div class="ov-sec">Ajouter depuis le catalogue</div>
<div class="cat-grid" id="catGrid"><div class="sr sm">Chargement…</div></div>
<!-- Add form (hidden by default) -->
<div class="af" id="addForm" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center">
<b id="afTitle">Nouvel équipement</b>
<button class="ov-x" onclick="hideForm()">✕</button>
</div>
<label>Type</label>
<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>
<label>Numéro de série / code-barre</label>
<input id="afSn" placeholder="Scanné ou saisi manuellement">
<label>Marque</label>
<input id="afBrand" placeholder="Ex: TP-Link, Huawei…">
<label>Modèle</label>
<input id="afModel" placeholder="Ex: HG8245H">
<label>Notes</label>
<input id="afNotes" placeholder="Port, emplacement…">
<label>Action</label>
<select id="afAction"><option value="install">Installer</option><option value="replace">Remplacer</option></select>
<button class="btn-add-full" id="afSubmit" onclick="submitEquip()">Ajouter l'équipement</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="fabs">
<button class="fab" style="background:#5c59a8;color:#fff" onclick="location.reload()">↻</button>
</div>
<script>
var T='${token}',H='${hub}',CJ='',CC='',CL='',stream=null,bdTimer=null;
function toast(m,ok){var t=document.getElementById('toast');t.textContent=m;t.style.background=ok?'#22c55e':'#ef4444';t.classList.add('on');setTimeout(function(){t.classList.remove('on')},2500)}
function doSt(j,s,b){b.classList.add('dis');b.textContent='…';fetch(H+'/t/'+T+'/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({job:j,status:s})}).then(function(r){return r.json()}).then(function(d){if(d.ok){toast(s==='Completed'?'Terminé ✓':'En route ▶',1);setTimeout(function(){location.reload()},600)}else{toast('Erreur',0);b.classList.remove('dis')}}).catch(function(){toast('Erreur réseau',0);b.classList.remove('dis')})}
// ── Equipment overlay ──
function openEquip(job,cust,loc){
CJ=job;CC=cust;CL=loc;
document.getElementById('eqTitle').textContent='📷 '+job;
document.getElementById('eqOv').classList.add('open');
document.getElementById('codeInp').value='';
document.getElementById('scanRes').innerHTML='';
hideForm();startCam();loadEquipList();loadCatalog();
}
function closeEquip(){document.getElementById('eqOv').classList.remove('open');stopCam()}
function startCam(){
var v=document.getElementById('vid');
if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){v.style.display='none';return}
navigator.mediaDevices.getUserMedia({video:{facingMode:'environment',width:{ideal:640},height:{ideal:480}}})
.then(function(s){stream=s;v.srcObject=s;v.style.display='block';
if('BarcodeDetector' in window){
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){document.getElementById('codeInp').value=c[0].rawValue;doScan()}}).catch(function(){})},500)
}
}).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}document.getElementById('vid').srcObject=null}
function doScan(){
var code=document.getElementById('codeInp').value.trim();if(!code)return;
var r=document.getElementById('scanRes');r.innerHTML='<div class="sr sm">Recherche…</div>';
fetch(H+'/t/'+T+'/scan',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:code})})
.then(function(x){return x.json()}).then(function(d){
if(!d.ok){r.innerHTML='<div class="sr" style="color:#ef4444">'+d.error+'</div>';return}
if(!d.results.length){r.innerHTML='<div class="sr">Aucun résultat — <a href="#" onclick="showForm(\\''+code+'\\');return false" style="color:#5c59a8;font-weight:600">Créer nouveau</a></div>';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>'+sn+'</b> '+nm+(st?' <span class="bdg" style="background:#818cf820;color:#818cf8">'+st+'</span>':'')+'</div>'}).join('')
}).catch(function(){r.innerHTML='<div class="sr" style="color:#ef4444">Erreur réseau</div>'})
}
function loadEquipList(){
var el=document.getElementById('eqList');el.innerHTML='<div class="sr sm">Chargement…</div>';
fetch(H+'/t/'+T+'/equip-list?job='+encodeURIComponent(CJ)).then(function(r){return r.json()}).then(function(d){
if(!d.ok||!d.items.length){el.innerHTML='<div class="sr sm">Aucun équipement</div>';return}
el.innerHTML=d.items.map(function(it){
var icon=it.action==='remove'?'🔴':'🟢';
return '<div class="eq-item"><div class="eq-info"><div class="eq-sn">'+icon+' '+(it.barcode||'—')+'</div><div class="eq-type">'+(it.equipment_type||'')+' '+(it.brand||'')+' '+(it.model||'')+'</div></div>'+(it.action!=='remove'?'<button class="eq-rm" onclick="removeEquip(\\''+it.name+'\\',\\''+it.barcode+'\\',\\''+it.equipment_type+'\\')">Retirer</button>':'')+'</div>'
}).join('')
}).catch(function(){el.innerHTML='<div class="sr sm">Erreur</div>'})
}
function loadCatalog(){
var g=document.getElementById('catGrid');g.innerHTML='<div class="sr sm">Chargement…</div>';
fetch(H+'/t/'+T+'/catalog').then(function(r){return r.json()}).then(function(d){
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(\\''+esc(it.item_name||it.name)+'\\',\\''+esc(it.name)+'\\',\\''+esc(it.item_group||'')+'\\')"><div class="cn">'+(it.item_name||it.name)+'</div><div class="cd">'+(it.item_group||'')+'</div>'+(it.standard_rate?'<div class="cp">'+Number(it.standard_rate).toFixed(2)+'$/mois</div>':'')+'</div>'}).join('')
}).catch(function(){g.innerHTML='<div class="sr sm">Erreur</div>'})
}
function esc(s){return (s||'').replace(/'/g,"\\\\'")}
function showForm(sn){
var f=document.getElementById('addForm');f.style.display='block';
document.getElementById('afSn').value=sn||'';document.getElementById('afBrand').value='';
document.getElementById('afModel').value='';document.getElementById('afNotes').value='';
document.getElementById('afTitle').textContent='Nouvel équipement';
f.scrollIntoView({behavior:'smooth'})
}
function showFormCat(name,code,group){
showForm(document.getElementById('codeInp').value||'');
document.getElementById('afTitle').textContent=name;
document.getElementById('afModel').value=code;
}
function hideForm(){document.getElementById('addForm').style.display='none'}
function submitEquip(){
var b=document.getElementById('afSubmit');b.disabled=true;b.textContent='Enregistrement…';
var data={job:CJ,customer:CC,location:CL,barcode:document.getElementById('afSn').value.trim(),equipment_type:document.getElementById('afType').value,brand:document.getElementById('afBrand').value.trim(),model:document.getElementById('afModel').value.trim(),notes:document.getElementById('afNotes').value.trim(),action:document.getElementById('afAction').value};
fetch(H+'/t/'+T+'/equip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})
.then(function(r){return r.json()}).then(function(d){
if(d.ok){toast('Équipement ajouté ✓',1);hideForm();loadEquipList()}else{toast('Erreur: '+(d.error||''),0)}
b.disabled=false;b.textContent="Ajouter l'équipement"
}).catch(function(){toast('Erreur réseau',0);b.disabled=false;b.textContent="Ajouter l'équipement"})
}
function removeEquip(name,sn,type){
if(!confirm('Retirer cet équipement ?'))return;
fetch(H+'/t/'+T+'/equip-remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({equipment:name,serial:sn,equipment_type:type,job:CJ})})
.then(function(r){return r.json()}).then(function(d){if(d.ok){toast('Retiré ✓',1);loadEquipList()}else{toast('Erreur',0)}}).catch(function(){toast('Erreur réseau',0)})
}
document.getElementById('codeInp').addEventListener('keydown',function(e){if(e.key==='Enter')doScan()})
</script></body></html>`
}
function pageExpired () {
return `<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#3f3d7a"><title>Lien expiré</title>
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,sans-serif;background:#eef1f5;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}.b{background:#fff;border-radius:16px;padding:32px 24px;text-align:center;max-width:340px;box-shadow:0 2px 12px rgba(0,0,0,.08)}.i{font-size:48px;margin-bottom:12px}h1{font-size:18px;margin-bottom:8px}p{font-size:14px;color:#64748b;line-height:1.5}</style>
</head><body><div class="b"><div class="i">🔗</div><h1>Lien expiré</h1><p>Ce lien n'est plus valide. Votre superviseur vous enverra un nouveau lien par SMS.</p></div></body></html>`
}
function pageError () {
return `<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Erreur</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#eef1f5;padding:24px}.b{background:#fff;border-radius:16px;padding:32px;text-align:center;max-width:340px}h1{font-size:18px;margin-bottom:8px}p{font-size:13px;color:#64748b}</style>
</head><body><div class="b"><h1>Erreur temporaire</h1><p>Réessayez dans quelques instants.</p></div></body></html>`
}
module.exports = { route }