gigafibre-fsm/services/targo-hub/lib/tech-mobile.js
louispaulb 01bb99857f refactor(targo-hub): add erp.js wrapper + migrate 7 lib files to it
Replaces hand-rolled `erpFetch` + `encodeURIComponent(JSON.stringify(...))`
URL building with a structured wrapper: erp.get/list/listRaw/create/update/
remove/getMany/hydrateLabels/raw.

Key wins:
- erp.list auto-retries up to 5 times when v16 rejects a fetched/linked
  field with "Field not permitted in query" — the field is dropped and the
  call continues, so callers don't have to know which fields v16 allows.
- erp.hydrateLabels batches link-label resolution (customer_name,
  service_location_name, …) in one query per link field — no N+1, no
  v16 breakage.
- Consistent {ok, error, status} shape for mutations.

Migrated call sites:
- otp.js: Customer email lookup + verifyOTP customer detail fetch +
  Contact email fallback + Service Location listing
- referral.js: Referral Credit fetch / update / generate
- tech-absence-sms.js: lookupTechByPhone, set/clear absence
- conversation.js: Issue archive create
- magic-link.js: Tech lookup for /refresh
- ical.js: Tech lookup + jobs listing for iCal feed
- tech-mobile.js: 13 erpFetch sites → erp wrapper

Remaining erpFetch callers (dispatch.js, acceptance.js, payments.js,
contracts.js, checkout.js, …) deliberately left untouched this pass —
they each have 10+ sites and need individual smoke-tests.

Live-tested against production ERPNext: tech-mobile page renders 54K
bytes, no runtime errors in targo-hub logs post-restart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:01:27 -04:00

959 lines
52 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 } = require('./helpers')
const erp = require('./erp')
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 tech = await erp.get('Dispatch Technician', techId, {
fields: ['name', 'full_name', 'phone', 'email', 'assigned_group'],
}) || { name: techId, full_name: techId }
// Real time field is `start_time`; customer_name / service_location_name
// are fetched-from links that v16 blocks in queries, so we pull the link
// IDs and resolve labels via hydrateLabels.
const jobs = await erp.list('Dispatch Job', {
fields: ['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'],
filters: [
['assigned_tech', '=', techId],
['scheduled_date', 'between', [rearDate, frontDate]],
],
orderBy: 'scheduled_date desc, start_time asc',
limit: 300,
})
await erp.hydrateLabels(jobs, {
customer: { doctype: 'Customer', out: 'customer_name', fields: ['name', 'customer_name'] },
service_location: { doctype: 'Service Location', out: 'service_location_name', fields: ['name', 'address_line_1', 'city'],
format: l => [l.address_line_1, l.city].filter(Boolean).join(', ') || l.name },
})
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())
}
}
// ═════════════════════════════════════════════════════════════════════════════
// 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 erp.update('Dispatch Job', body.job, { notes: body.notes || '' })
if (!r.ok) return json(res, r.status || 500, { error: r.error })
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 erp.create('File', {
file_name: fileName, is_private: 1, content: base64, decode: 1,
attached_to_doctype: 'Dispatch Job', attached_to_name: body.job,
})
if (!r.ok) return json(res, r.status || 500, { error: r.error })
return json(res, 200, { ok: true, name: r.data?.name, file_url: r.data?.file_url, file_name: r.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 items = await erp.list('File', {
fields: ['name', 'file_name', 'file_url', 'is_private', 'creation'],
filters: [
['attached_to_doctype', '=', 'Dispatch Job'],
['attached_to_name', '=', jobName],
['file_name', 'like', 'dj-%'],
],
orderBy: 'creation desc',
limit: 50,
})
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 job = await erp.get('Dispatch Job', name)
if (!job) return json(res, 404, { error: 'not found' })
return json(res, 200, { ok: true, job })
} 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 rows = await erp.list('Service Equipment', {
fields: ['name', 'serial_number', 'item_name', 'equipment_type', 'mac_address', 'status', 'customer', 'service_location'],
filters: [[field, 'like', `%${q}%`]],
limit: 5,
})
if (rows.length) return json(res, 200, { ok: true, results: rows, source: 'Service Equipment', query: q })
}
const sn = await erp.list('Serial No', {
fields: ['name', 'serial_no', 'item_code', 'status', 'warehouse'],
filters: [['serial_no', 'like', `%${q}%`]],
limit: 5,
})
if (sn.length) return json(res, 200, { ok: true, results: sn, 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 erp.create('Equipment Install', installData)
if (!r.ok) return json(res, r.status || 500, { error: r.error })
if (barcode && (action === 'install' || action === 'replace')) {
try {
const q = barcode.replace(/[:\-\s]/g, '').toUpperCase()
const existing = await erp.list('Service Equipment', {
fields: ['name'], filters: [['serial_number', 'like', `%${q}%`]], limit: 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.length) {
await erp.update('Service Equipment', existing[0].name, seUpdate)
} else {
await erp.create('Service Equipment', { serial_number: barcode, ...seCommon })
}
} catch (e) { log('Equip link/create warn:', e.message) }
}
return json(res, 200, { ok: true, name: r.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 erp.update('Service Equipment', body.equipment, {
status: body.status || 'Retourné', customer: '', service_location: '',
})
await erp.create('Equipment Install', {
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 items = await erp.list('Item', {
fields: ['name', 'item_name', 'item_group', 'standard_rate', 'image', 'description'],
filters: [['item_group', 'in', ['Network Equipment', 'CPE', 'Équipements réseau', 'Products']]],
limit: 50,
})
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 items = await erp.list('Equipment Install', {
fields: ['name', 'barcode', 'equipment_type', 'brand', 'model', 'action', 'notes', 'installation_date'],
filters: [['request', '=', jobName]],
orderBy: 'creation desc',
limit: 20,
})
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 }