gigafibre-fsm/services/targo-hub/lib/tech-mobile.js
louispaulb 1d23aa7814 feat(tech-mobile): SPA redesign with tabs, detail view, notes, photos, field-scan
Rewrote msg.gigafibre.ca (tech magic-link page) from a today-only flat list
into a proper 4-tab SPA:
- Aujourd'hui: In Progress / En retard / Aujourd'hui / Sans date / À venir
- Calendrier: placeholder (phase 4)
- Historique: searchable + filter chips (Tous/Terminés/Manqués/Annulés)
- Profil: tech info, support line, refresh

Job detail view (hash-routed, #job/DJ-xxx):
- Customer + tap-to-call/navigate block
- Editable notes (textarea → PUT /api/resource/Dispatch Job)
- Photo upload (base64 → File doctype, is_private, proxied back via /photo-serve)
- Equipment section (inherited from overlay)
- Sticky action bar (Démarrer / Terminer)

Equipment overlay extended with per-field Gemini Vision scanners. Each
input (SN, MAC, GPON SN, Wi-Fi SSID, Wi-Fi PWD, model) has a 📷 that opens
a capture modal; Gemini is prompted to find THAT field specifically and
returns value+confidence. Tech confirms or retries before the value fills in.

Root cause of the "tech can't see his job" bug: page filtered
scheduled_date=today, so jobs on any other day were invisible even though
the token was tech-scoped. Now fetches a ±60d window and groups client-side.

vision.js: new extractField(base64, field, ctx) helper + handleFieldScan
route (used by new /t/:token/field-scan endpoint).

Also fixes discovered along the way:
- Frappe v16 blocks fetched/linked fields (customer_name, service_location_name)
  and phantom fields (scheduled_time — real one is start_time). Query now
  uses only own fields; names resolved in two batch follow-up queries.
- "Today" is Montreal-local, not UTC. Prevents evening jobs being mislabeled
  as "hier" when UTC has already rolled to the next day.

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

1196 lines
70 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')
// ── Tech mobile SPA ──────────────────────────────────────────────────────────
// Server-rendered shell + hash-routed client views (home/hist/cal/profile/job-detail).
// No framework. 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 helpers ─────────────────────────────────────────────────────────────
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 }
}
// YYYY-MM-DD in America/Montreal regardless of server TZ
function montrealDate (d = new Date()) {
return d.toLocaleDateString('en-CA', { timeZone: 'America/Montreal', year: 'numeric', month: '2-digit', day: '2-digit' })
}
// ── Route dispatcher ─────────────────────────────────────────────────────────
async function route (req, res, method, path) {
// GET /t/{token} → page shell (server-rendered, all jobs embedded)
if (method === 'GET' && /^\/t\/[A-Za-z0-9_\-\.]+$/.test(path)) return handlePage(req, res, path)
// POST /t/{token}/status → set status + walk chain
if (method === 'POST' && path.endsWith('/status')) return handleStatus(req, res, path)
// POST /t/{token}/note → save notes on a Dispatch Job
if (method === 'POST' && path.endsWith('/note')) return handleNote(req, res, path)
// POST /t/{token}/photo → upload photo (base64) attached to a job
if (method === 'POST' && path.endsWith('/photo')) return handlePhotoUpload(req, res, path)
// GET /t/{token}/photos?job= → list photos on a job
if (method === 'GET' && path.endsWith('/photos')) return handlePhotoList(req, res, path)
// GET /t/{token}/photo-serve?p=/private/files/... → proxy a private file
if (method === 'GET' && path.endsWith('/photo-serve')) return handlePhotoServe(req, res, path)
// GET /t/{token}/job?name= → refresh a single job
if (method === 'GET' && path.endsWith('/job')) return handleJobDetail(req, res, path)
// POST /t/{token}/field-scan → Gemini single-field extraction (SN, MAC, SSID, etc)
if (method === 'POST' && path.endsWith('/field-scan')) return handleFieldScan(req, res, path)
// POST /t/{token}/scan → known-code lookup in Service Equipment / Serial No
if (method === 'POST' && path.endsWith('/scan')) return handleScan(req, res, path)
// POST /t/{token}/vision → generic multi-barcode extraction
if (method === 'POST' && path.endsWith('/vision')) return handleVision(req, res, path)
// POST /t/{token}/equip → create Equipment Install + Service Equipment
if (method === 'POST' && path.endsWith('/equip')) return handleEquipInstall(req, res, path)
// POST /t/{token}/equip-remove
if (method === 'POST' && path.endsWith('/equip-remove')) return handleEquipRemove(req, res, path)
// GET /t/{token}/catalog
if (method === 'GET' && path.endsWith('/catalog')) return handleCatalog(req, res, path)
// GET /t/{token}/equip-list?job=
if (method === 'GET' && path.endsWith('/equip-list')) return handleEquipList(req, res, path)
return json(res, 404, { error: 'Not found' })
}
// ── Shell + data fetch ───────────────────────────────────────────────────────
async function handlePage (req, res, path) {
const payload = authToken(path)
if (!payload) { res.writeHead(200, html()); return res.end(pageExpired()) }
const techId = payload.sub
// Use Montreal local time for "today" — UTC midnight rolls over 4-5 hours
// before Montreal midnight, which would mislabel evening jobs as "hier".
const today = montrealDate(new Date())
const rearDate = montrealDate(new Date(Date.now() - 60 * 86400 * 1000))
const frontDate = 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 blocks fields not in the doctype DocField list. Real time field is
// `start_time` — `scheduled_time` was a phantom field that v15 silently
// ignored. We fetch link IDs and resolve names separately.
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 : []
// Resolve customer + service_location labels in two batch queries
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 = {}
const locNames = {}
if (custIds.length) {
try {
const cf = encodeURIComponent(JSON.stringify([['name', 'in', custIds]]))
const cr = await erpFetch(`/api/resource/Customer?filters=${cf}&fields=${encodeURIComponent(JSON.stringify(['name', 'customer_name']))}&limit_page_length=200`)
for (const c of (cr.data?.data || [])) custNames[c.name] = c.customer_name || c.name
} catch (_) { /* non-fatal */ }
}
if (locIds.length) {
try {
const lf = encodeURIComponent(JSON.stringify([['name', 'in', locIds]]))
const lr = await erpFetch(`/api/resource/Service%20Location?filters=${lf}&fields=${encodeURIComponent(JSON.stringify(['name', 'address_line_1', 'city']))}&limit_page_length=200`)
for (const l of (lr.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 || ''
}
res.writeHead(200, html())
res.end(renderPage({ tech, jobs, token: path.split('/')[2], today }))
} catch (e) {
log('tech-mobile page error:', e.message)
res.writeHead(500, html())
res.end(pageError())
}
}
// ── Status update (reuse dispatch chain walker) ──────────────────────────────
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 { 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 }) }
}
// ── Notes save ───────────────────────────────────────────────────────────────
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 }) }
}
// ── Photo upload (base64 → File doc, attached to job) ───────────────────────
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 from ERPNext so the tech browser can render it without
// carrying ERPNext credentials. Query param `p` must be a /private/files/ path.
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()
}
}
// ── Single-job refresh (detail view uses embedded data, but can refresh) ─────
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 }) }
}
// ── Gemini field-scan ────────────────────────────────────────────────────────
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 })
}
}
// ── Barcode lookup in ERPNext ───────────────────────────────────────────────
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 }) }
}
// ── Equipment Install / Remove / Catalog ────────────────────────────────────
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 {
// 1. Equipment Install log
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) })
// 2. Link/create Service Equipment when installing/replacing
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 || '',
}
// Drop undefined/empty string keys so we don't wipe existing data on update
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 }) }
}
// ═════════════════════════════════════════════════════════════════════════════
// HTML
// ═════════════════════════════════════════════════════════════════════════════
function html () { return { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store' } }
function esc (s) { return (s == null ? '' : String(s)).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;') }
function fmtTime (t) { if (!t) return ''; const [h, m] = String(t).split(':'); return `${h}h${m || '00'}` }
function fmtDate (d) {
if (!d) return ''
try { return new Date(d + 'T00:00:00').toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' }) }
catch { return d }
}
function dateLabelFr (d, today) {
if (!d) return 'Sans date'
if (d === today) return "Aujourd'hui"
try {
const diff = Math.round((new Date(d + 'T00:00:00') - new Date(today + 'T00:00:00')) / 86400000)
if (diff === -1) return 'Hier'
if (diff === 1) return 'Demain'
if (diff > 1 && diff <= 6) return new Date(d + 'T00:00:00').toLocaleDateString('fr-CA', { weekday: 'long' })
return fmtDate(d)
} catch { return fmtDate(d) }
}
function todayFr () { return new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' }) }
const STATUS_META = {
Scheduled: { label: 'Planifié', color: '#818cf8' },
assigned: { label: 'Assigné', color: '#818cf8' },
open: { label: 'Ouvert', color: '#818cf8' },
'In Progress':{ label: 'En cours', color: '#f59e0b' },
in_progress: { label: 'En cours', color: '#f59e0b' },
Completed: { label: 'Terminé', color: '#22c55e' },
Cancelled: { label: 'Annulé', color: '#94a3b8' },
}
function badge (s) {
const m = STATUS_META[s] || { label: s || '—', color: '#94a3b8' }
return `<span class="bdg" style="background:${m.color}22;color:${m.color}">${esc(m.label)}</span>`
}
function jobCard (j, today) {
const urgent = j.priority === 'urgent' || j.priority === 'high'
const done = j.status === 'Completed' || j.status === 'Cancelled'
const border = urgent ? '#ef4444' : done ? '#94a3b8' : j.status === 'In Progress' || j.status === 'in_progress' ? '#f59e0b' : '#5c59a8'
const overdue = !done && j.scheduled_date && j.scheduled_date < today
const dlbl = dateLabelFr(j.scheduled_date, today)
const tlbl = j.start_time ? fmtTime(j.start_time) : ''
const datePart = [dlbl, tlbl].filter(Boolean).join(' · ')
return `<div class="card${done ? ' dim' : ''}${overdue ? ' od' : ''}" onclick="go('#job/${esc(j.name)}')" style="border-left:4px solid ${border}">
<div class="row"><span class="jid">${esc(j.name)}</span>${badge(j.status)}<span class="jdt">${esc(datePart)}</span></div>
<div class="jtt">${esc(j.subject || 'Sans titre')}</div>
${j.customer_name ? `<div class="jsb">👤 ${esc(j.customer_name)}</div>` : ''}
${j.service_location_name ? `<div class="jsb">📍 ${esc(j.service_location_name)}</div>` : ''}
<div class="jmt">${j.duration_h ? `${j.duration_h}h` : ''} ${j.job_type ? esc(j.job_type) : ''}${urgent ? ' 🔥' : ''}</div>
</div>`
}
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 || ''
// Jobs embedded as a lookup table by name — detail view reads from here
const jobsMap = {}
for (const j of jobs) jobsMap[j.name] = j
const jobsJson = JSON.stringify(jobsMap).replace(/</g, '\\u003c').replace(/-->/g, '--\\u003e')
const sec = (title, list, mod = '') => list.length
? `<div class="sec${mod ? ' ' + mod : ''}">${title} <span class="cnt">${list.length}</span></div>${list.map(j => jobCard(j, today)).join('')}`
: ''
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;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;color:#0f172a;-webkit-text-size-adjust:100%;padding-bottom:68px;min-height:100vh}
.hdr{background:linear-gradient(135deg,#3f3d7a,#5c59a8);color:#fff;padding:14px 16px 28px;border-radius:0 0 18px 18px;position:relative;z-index:1}
.hdr-d{font-size:12px;opacity:.75;text-transform:capitalize}
.hdr-n{font-size:19px;font-weight:700;margin:2px 0 12px;display:flex;align-items:center;gap:8px}
.hdr-n .ic{width:32px;height:32px;border-radius:50%;background:rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;font-size:16px}
.sts{display:flex;gap:8px}
.st{flex:1;background:rgba(255,255,255,.14);border-radius:10px;padding:10px 4px;text-align:center;cursor:pointer;transition:background .15s}
.st:active{background:rgba(255,255,255,.24)}
.st b{display:block;font-size:19px;font-weight:700}.st small{font-size:10px;opacity:.75;letter-spacing:.3px}
.wrap{padding:14px 12px;margin-top:-14px}
.view{display:none}.view.active{display:block}
.sec{font-size:11px;font-weight:700;color:#5c59a8;letter-spacing:1.2px;margin:16px 4px 8px;text-transform:uppercase;display:flex;align-items:center;gap:8px}
.sec .cnt{background:#ddd8ff;color:#5c59a8;padding:1px 7px;border-radius:10px;font-size:10px}
.sec.danger{color:#ef4444}.sec.danger .cnt{background:#fee2e2;color:#ef4444}
.card{background:#fff;border-radius:12px;padding:12px;margin-bottom:10px;box-shadow:0 1px 2px rgba(15,23,42,.06);cursor:pointer;transition:transform .1s}
.card:active{transform:scale(.985)}
.card.dim{opacity:.6}.card.od{box-shadow:0 1px 2px rgba(239,68,68,.15),0 0 0 1px #fecaca}
.row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.jid{font-size:10px;font-weight:700;color:#5c59a8;letter-spacing:.3px}
.jdt{margin-left:auto;font-size:11px;color:#64748b;font-weight:600;text-align:right;text-transform:capitalize}
.bdg{padding:2px 8px;border-radius:8px;font-size:10px;font-weight:700;letter-spacing:.2px}
.jtt{font-size:15px;font-weight:600;margin:6px 0 2px;color:#0f172a;line-height:1.25}
.jsb{font-size:12px;color:#64748b;margin:1px 0}
.jmt{font-size:11px;color:#94a3b8;margin:4px 0 0}
.empty{text-align:center;padding:50px 24px;color:#94a3b8}
.empty .em{font-size:44px;margin-bottom:8px;opacity:.5}
/* Search bar */
.sbar{position:sticky;top:0;background:#f1f5f9;padding:10px 4px 8px;z-index:5;margin:0 -4px}
.sinp{width:100%;padding:10px 14px;border:1.5px solid #e2e8f0;border-radius:12px;font-size:14px;background:#fff;outline:none}
.sinp:focus{border-color:#5c59a8}
/* Tab bar */
.tbar{position:fixed;bottom:0;left:0;right:0;background:#fff;border-top:1px solid #e2e8f0;display:flex;height:62px;z-index:40;padding-bottom:env(safe-area-inset-bottom,0)}
.tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;color:#94a3b8;font-size:10px;font-weight:600;cursor:pointer;border:none;background:none}
.tab .ic{font-size:22px;line-height:1}
.tab.on{color:#5c59a8}
/* Toast */
.toast{position:fixed;top:14px;left:12px;right:12px;padding:12px 14px;border-radius:10px;color:#fff;font-size:13px;font-weight:600;text-align:center;z-index:300;opacity:0;transform:translateY(-12px);transition:all .25s;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,.2)}
.toast.on{opacity:1;transform:translateY(0)}
/* Detail view */
#view-detail{background:#f1f5f9}
.dt-hdr{background:linear-gradient(135deg,#3f3d7a,#5c59a8);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}
.panel{background:#fff;border-radius:12px;padding:14px;margin:12px 12px 0;box-shadow:0 1px 2px rgba(15,23,42,.06)}
.panel h3{font-size:11px;font-weight:700;color:#5c59a8;letter-spacing:1.2px;margin-bottom:8px;text-transform:uppercase;display:flex;align-items:center;gap:8px}
.panel h3 .rt{margin-left:auto;font-size:10px;color:#94a3b8;font-weight:600;letter-spacing:.5px}
.cust-line{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid #f1f5f9;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:#5c59a8;margin-left:auto;text-decoration:none;font-weight:600;font-size:13px;padding:6px 10px;background:#eef2ff;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 #e2e8f0;border-radius:10px;font-size:14px;font-family:inherit;resize:vertical;outline:none;line-height:1.4}
.notes-area:focus{border-color:#5c59a8}
.savebtn{margin-top:8px;padding:10px 16px;border:none;border-radius:10px;background:#5c59a8;color:#fff;font-weight:600;font-size:14px;cursor:pointer;width:100%}
.savebtn:active{background:#3f3d7a}.savebtn:disabled{opacity:.5}
.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:#f1f5f9}
.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:#94a3b8;cursor:pointer;background:#f8fafc}
.photo-add:active{background:#eef2ff;border-color:#5c59a8;color:#5c59a8}
.eq-item{background:#f8fafc;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:#0f172a;font-size:13px}.eq-type{font-size:11px;color:#64748b;margin-top:2px}
.eq-rm{background:#fef2f2;color:#ef4444;border:none;border-radius:6px;padding:6px 10px;font-size:12px;font-weight:600;cursor:pointer}
.add-eq-btn{width:100%;padding:14px;background:#eef2ff;color:#5c59a8;border:2px dashed #c7d2fe;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;margin-top:6px}
.add-eq-btn:active{background:#e0e7ff}
.action-bar{position:sticky;bottom:68px;left:0;right:0;background:#fff;border-top:1px solid #e2e8f0;padding:10px 12px;display:flex;gap:8px;z-index:30;margin:20px -12px 0}
.action-bar .btn{flex:1;padding:14px;border:none;border-radius:10px;font-weight:700;font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px}
.btn-nav{background:#eef2ff;color:#4f46e5}
.btn-start{background:#5c59a8;color:#fff}.btn-start:active{background:#3f3d7a}
.btn-finish{background:#22c55e;color:#fff}.btn-finish:active{background:#16a34a}
.dis{opacity:.5;pointer-events:none}
/* Equipment overlay */
.ov{display:none;position:fixed;inset:0;background:#f1f5f9;z-index:100;flex-direction:column;overflow-y:auto}
.ov.open{display:flex}
.ov-hdr{display:flex;align-items:center;padding:12px 14px;background:#fff;border-bottom:1px solid #e2e8f0;position:sticky;top:0;z-index:2;gap:8px}
.ov-hdr h2{font-size:16px;font-weight:700;flex:1;margin:0;color:#0f172a}
.ov-x{background:none;border:none;font-size:22px;cursor:pointer;padding:4px 8px;color:#64748b;width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:8px}
.ov-x:active{background:#f1f5f9}
.ov-body{padding:14px;flex:1}
.ov-sec{font-size:11px;font-weight:700;color:#5c59a8;letter-spacing:1.2px;margin:16px 0 8px;text-transform:uppercase}
.cam-wrap{position:relative;width:100%;max-width:400px;margin:0 auto 10px;border-radius:12px;overflow:hidden;background:#000;aspect-ratio:4/3}
.cam-wrap video,.cam-wrap canvas{width:100%;display:block}
.cam-overlay{position:absolute;inset:0;pointer-events:none;border:2px solid rgba(92,89,168,.6);border-radius:12px}
.inp-row{display:flex;gap:8px;margin-bottom:10px}
.inp{flex:1;padding:10px 12px;border:2px solid #e2e8f0;border-radius:8px;font-size:15px;background:#fff;outline:none}
.inp:focus{border-color:#5c59a8}
.btn-sm{padding:10px 14px;border:none;border-radius:8px;background:#5c59a8;color:#fff;font-weight:600;font-size:14px;cursor:pointer}
.btn-sm:active{background:#3f3d7a}
.sr{background:#fff;border-radius:10px;padding:10px 12px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.04);font-size:13px;color:#334155}
.sr b{color:#5c59a8}.sr .sm{font-size:11px;color:#94a3b8}
.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:all .15s}
.cat-card:active{border-color:#5c59a8;background:#eef2ff}
.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:13px;font-weight:700;color:#5c59a8;margin-top:4px}
.af{background:#fff;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:#0f172a}
.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 #e2e8f0;border-radius:8px;font-size:14px;background:#fff;outline:none}
.af .fwrap input:focus,.af .fwrap select:focus{border-color:#5c59a8}
.af .fscan{width:42px;height:auto;border:none;border-radius:8px;background:#eef2ff;color:#5c59a8;font-size:18px;cursor:pointer;flex-shrink:0}
.af .fscan:active{background:#e0e7ff}
.btn-add-full{width:100%;padding:14px;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}
/* Field-scan nested overlay */
.fs-ov{display:none;position:fixed;inset:0;background:rgba(15,23,42,.92);z-index:200;flex-direction:column;align-items:center;justify-content:center;padding:20px}
.fs-ov.open{display:flex}
.fs-box{background:#fff;border-radius:16px;width:100%;max-width:480px;max-height:90vh;overflow-y:auto;padding:16px}
.fs-box h3{font-size:15px;margin-bottom:10px;color:#0f172a;display:flex;align-items:center;gap:8px}
.fs-box .fs-close{margin-left:auto;background:none;border:none;font-size:22px;color:#64748b;cursor:pointer}
.fs-cam{width:100%;aspect-ratio:4/3;background:#000;border-radius:12px;overflow:hidden;margin-bottom:10px}
.fs-cam video{width:100%;height:100%;object-fit:cover}
.fs-btn{display:flex;gap:8px;margin-top:10px}
.fs-btn button{flex:1;padding:12px;border:none;border-radius:10px;font-weight:700;font-size:14px;cursor:pointer}
.fs-capture{background:#5c59a8;color:#fff}.fs-capture:active{background:#3f3d7a}
.fs-cancel{background:#f1f5f9;color:#64748b}
.conf-bar{height:4px;background:#f1f5f9;border-radius:2px;margin-top:6px;overflow:hidden}
.conf-bar .f{height:100%;transition:width .3s}
.fs-val{font-size:22px;font-weight:700;color:#0f172a;margin:10px 0 4px;text-align:center;font-family:ui-monospace,monospace}
.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 #e2e8f0;background:#fff;color:#64748b;border-radius:16px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
.hist-filter button.on{border-color:#5c59a8;background:#5c59a8;color:#fff}
.placeholder{background:#fff;border-radius:12px;padding:40px 24px;text-align:center;color:#94a3b8;margin-top:14px}
.placeholder .em{font-size:48px;margin-bottom:10px;opacity:.5}
.placeholder h2{font-size:16px;color:#475569;margin-bottom:6px}
.placeholder p{font-size:13px;line-height:1.5}
</style></head><body>
<!-- ═══ HOME VIEW ═══ -->
<div class="view active" id="view-home">
<div class="hdr">
<div class="hdr-d">${esc(todayFr())}</div>
<div class="hdr-n"><span class="ic">👷</span>${esc(techName)}</div>
<div class="sts">
<div class="st"><b id="stActive">${activeCount}</b><small>À FAIRE</small></div>
<div class="st"><b>${inProgress.length}</b><small>EN COURS</small></div>
<div class="st"><b>${history.length}</b><small>TERMINÉS</small></div>
</div>
</div>
<div class="wrap">
${activeCount === 0 && inProgress.length === 0 ? `
<div class="empty">
<div class="em">🎉</div>
<p>Aucune tâche active.<br/>Profitez de la pause !</p>
</div>` : ''}
${sec('En cours', inProgress)}
${sec('En retard', overdue, 'danger')}
${sec("Aujourd'hui", todayJobs)}
${sec('Sans date', nodate)}
${sec('À venir', upcoming)}
</div>
</div>
<!-- ═══ HISTORIQUE VIEW ═══ -->
<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 (${history.length + overdue.length})</button>
<button data-f="done">Terminés (${history.filter(j=>j.status==='Completed').length})</button>
<button data-f="overdue">Manqués (${overdue.length})</button>
<button data-f="cancelled">Annulés (${history.filter(j=>j.status==='Cancelled').length})</button>
</div>
<div id="histList">
${[...overdue, ...history].map(j => jobCard(j, today)).join('') || '<div class="empty"><div class="em">📭</div><p>Aucun historique.</p></div>'}
</div>
</div>
</div>
<!-- ═══ CALENDRIER VIEW (placeholder) ═══ -->
<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">
<div class="placeholder">
<div class="em">🚧</div>
<h2>Bientôt disponible</h2>
<p>La vue calendrier avec navigation mois/semaine arrive à la phase 4.<br/>Pour l'instant, utilisez <b>Aujourd'hui</b> ou <b>Historique</b>.</p>
</div>
</div>
</div>
<!-- ═══ PROFIL VIEW ═══ -->
<div class="view" id="view-profile">
<div class="hdr">
<div class="hdr-d">Profil</div>
<div class="hdr-n"><span class="ic">👤</span>${esc(techName)}</div>
</div>
<div class="wrap">
<div class="panel">
<h3>Informations</h3>
<div class="cust-line"><span class="ico">🪪</span><span>${esc(tech.name)}</span></div>
${tech.phone ? `<div class="cust-line"><span class="ico">📞</span><span>${esc(tech.phone)}</span><a href="tel:${esc(tech.phone)}">Appeler</a></div>` : ''}
${tech.email ? `<div class="cust-line"><span class="ico">✉️</span><span>${esc(tech.email)}</span></div>` : ''}
${tech.assigned_group ? `<div class="cust-line"><span class="ico">👥</span><span>${esc(tech.assigned_group)}</span></div>` : ''}
</div>
<div class="panel">
<h3>Support</h3>
<div class="cust-line"><span class="ico">📞</span><span>Ligne support</span><a href="tel:4382313838">438-231-3838</a></div>
</div>
<div class="panel" style="text-align:center;padding:20px">
<button class="savebtn" style="background:#f1f5f9;color:#64748b" onclick="location.reload()">↻ Actualiser les données</button>
</div>
</div>
</div>
<!-- ═══ JOB DETAIL VIEW (filled by JS) ═══ -->
<div class="view" id="view-detail"></div>
<!-- ═══ Equipment overlay ═══ -->
<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-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-add-full" id="afSubmit" onclick="submitEquip()">Ajouter l'équipement</button>
</div>
</div>
</div>
<!-- ═══ Field-scan nested overlay ═══ -->
<div class="fs-ov" id="fsOv">
<div class="fs-box">
<h3><span id="fsIcon">📷</span><span id="fsLabel">Scanner</span><button class="fs-close" onclick="closeFieldScan()">✕</button></h3>
<div class="fs-cam"><video id="fsVid" autoplay playsinline muted></video></div>
<canvas id="fsCnv" style="display:none"></canvas>
<div id="fsResult"></div>
<div class="fs-btn">
<button class="fs-cancel" onclick="closeFieldScan()">Annuler</button>
<button class="fs-capture" onclick="captureFieldScan()">📸 Capturer</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- ═══ Bottom tab bar ═══ -->
<div class="tbar">
<button class="tab on" data-v="home" onclick="go('#home')"><span class="ic">📅</span>Aujourd'hui</button>
<button class="tab" data-v="cal" onclick="go('#cal')"><span class="ic">📆</span>Calendrier</button>
<button class="tab" data-v="hist" onclick="go('#hist')"><span class="ic">📋</span>Historique</button>
<button class="tab" data-v="profile" onclick="go('#profile')"><span class="ic">👤</span>Profil</button>
</div>
<script>
var T=${JSON.stringify(token)};
var H=${JSON.stringify(hub)};
var TODAY=${JSON.stringify(today)};
var JOBS=${jobsJson};
var CJ='',CC='',CL='',CMODEL='',CTYPE='',CBRAND='';
var stream=null,bdTimer=null,fsStream=null,fsField='',fsFieldLabel='',fsCtx={},fsCallback=null;
// ── Helpers ─────────────────────────────────────────────────────────────
function $(id){return document.getElementById(id)}
function esc(s){return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
function toast(m,ok){var t=$('toast');t.textContent=m;t.style.background=ok?'#22c55e':'#ef4444';t.classList.add('on');setTimeout(function(){t.classList.remove('on')},2500)}
function fmtTime(t){if(!t)return '';var p=String(t).split(':');return p[0]+'h'+(p[1]||'00')}
function dlbl(d){if(!d)return 'Sans date';if(d===TODAY)return "Aujourd'hui";try{var diff=Math.round((new Date(d+'T00:00:00')-new Date(TODAY+'T00:00:00'))/86400000);if(diff===-1)return 'Hier';if(diff===1)return 'Demain';return new Date(d+'T00:00:00').toLocaleDateString('fr-CA',{weekday:'short',day:'numeric',month:'short'})}catch(e){return d}}
function go(h){location.hash=h}
// ── Hash routing ────────────────────────────────────────────────────────
function route(){
var h=location.hash||'#home';
// Close overlays on nav (but not if we're opening a detail)
var jm=h.match(/^#job\\/(.+)$/);
if(!jm){closeEquip();closeFieldScan();}
// Job detail
if(jm){openDetail(decodeURIComponent(jm[1]));return}
// Main tab
var v=h.slice(1);if(['home','cal','hist','profile'].indexOf(v)<0)v='home';
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)}
window.scrollTo(0,0);
}
window.addEventListener('hashchange',route);route();
// ── Status update ───────────────────────────────────────────────────────
function doSt(j,s,btn){
if(btn){btn.classList.add('dis');btn.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||d.status==='In Progress'||d.status==='Completed'){
toast(s==='Completed'?'Terminé ✓':'En route ▶',true);
if(JOBS[j])JOBS[j].status=s;
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 ──────────────────────────────────────────
var histAll=document.querySelectorAll('#histList .card');
var histSearch=$('histSearch');
if(histSearch)histSearch.addEventListener('input',function(){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 q=(histSearch?histSearch.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 jid=c.querySelector('.jid').textContent;
var j=JOBS[jid];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);
// Async refresh secondary data
loadPhotos();loadEquipList();
window.scrollTo(0,0);
}
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 gps=j.service_location_name?'https://www.google.com/maps/dir/?api=1&destination='+encodeURIComponent(j.service_location_name):'';
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||'';
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)+(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>'
// Customer + 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 (editable)
+'<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="savebtn" 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-nav">📍 Naviguer</a>':'')
+ (canStart?'<button class="btn btn-start" onclick="doSt(\\''+j.name+'\\',\\'In Progress\\',this)">▶ Démarrer</button>':'')
+ (canFinish?'<button class="btn btn-finish" 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…';
fetch(H+'/t/'+T+'/note',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({job:CJ,notes:el.value})})
.then(function(r){return r.json()}).then(function(d){
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;
fetch(H+'/t/'+T+'/photos?job='+encodeURIComponent(CJ)).then(function(r){return r.json()}).then(function(d){
if(!el)return;
var add='<div class="photo-add" onclick="$(\\'photoInp\\').click()">+</div>';
if(!d.ok){el.innerHTML=add;return}
var items=d.items||[];
el.innerHTML=add+items.map(function(ph){
var src=H+'/t/'+T+'/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);
fetch(H+'/t/'+T+'/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({job:CJ,image:e.target.result,file_name:f.name})})
.then(function(r){return r.json()}).then(function(d){
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(){$('eqOv').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>';
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||'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>';
// Also pre-fill the form serial field
$('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(){
// Render in BOTH the overlay list and the detail panel (if visible)
var urls=[['eqList',true],['dtEquipList',false]];
fetch(H+'/t/'+T+'/equip-list?job='+encodeURIComponent(CJ)).then(function(r){return r.json()}).then(function(d){
for(var i=0;i<urls.length;i++){
var el=$(urls[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'&&urls[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>';
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(&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,
};
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é ✓',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;
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é ✓',true);loadEquipList()}else{toast('Erreur',false)}}).catch(function(){toast('Erreur réseau',false)});
}
$('codeInp').addEventListener('keydown',function(e){if(e.key==='Enter')doScan()});
// ── Field-targeted scan (Gemini) ────────────────────────────────────────
function scanInto(field,label,targetId){
openFieldScan(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():'',
});
}
function openFieldScan(field,label,callback,ctx){
fsField=field;fsFieldLabel=label;fsCallback=callback;fsCtx=ctx||{};
$('fsLabel').textContent='Scanner : '+label;
$('fsResult').innerHTML='<div class="sr sm" style="text-align:center">Cadrez l\\'étiquette puis appuyez sur <b>Capturer</b></div>';
$('fsOv').classList.add('open');
var v=$('fsVid');
if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){v.style.display='none';$('fsResult').innerHTML='<div class="sr" style="color:#ef4444">Caméra indisponible</div>';return}
navigator.mediaDevices.getUserMedia({video:{facingMode:'environment',width:{ideal:1280},height:{ideal:720}}}).then(function(s){
fsStream=s;v.srcObject=s;v.style.display='block';
}).catch(function(e){
$('fsResult').innerHTML='<div class="sr" style="color:#ef4444">Caméra refusée: '+(e.message||'')+'</div>';
});
}
function closeFieldScan(){
$('fsOv').classList.remove('open');
if(fsStream){fsStream.getTracks().forEach(function(t){t.stop()});fsStream=null}
var v=$('fsVid');if(v)v.srcObject=null;
fsCallback=null;
}
function captureFieldScan(){
var v=$('fsVid'),c=$('fsCnv');
if(!v.videoWidth){toast('Caméra non prête',false);return}
c.width=v.videoWidth;c.height=v.videoHeight;
var ctx=c.getContext('2d');ctx.drawImage(v,0,0,c.width,c.height);
var b64=c.toDataURL('image/jpeg',0.85);
$('fsResult').innerHTML='<div class="sr sm" style="text-align:center">🤖 Analyse par Gemini Vision…</div>';
var payload={image:b64,field:fsField};
if(fsCtx.equipment_type)payload.equipment_type=fsCtx.equipment_type;
if(fsCtx.brand)payload.brand=fsCtx.brand;
if(fsCtx.model)payload.model=fsCtx.model;
fetch(H+'/t/'+T+'/field-scan',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
.then(function(r){return r.json()}).then(function(d){
if(!d.ok||!d.value){$('fsResult').innerHTML='<div class="sr" style="color:#ef4444;text-align:center">❌ Non détecté — rapprochez-vous et réessayez.</div>';return}
var pct=Math.round((d.confidence||0)*100);
var color=pct>=70?'#22c55e':pct>=40?'#f59e0b':'#ef4444';
$('fsResult').innerHTML=''
+'<div class="sr">'
+ '<div class="fs-val">'+esc(d.value)+'</div>'
+ '<div class="sm" style="text-align:center">Confiance : <b style="color:'+color+'">'+pct+'%</b></div>'
+ '<div class="conf-bar"><div class="f" style="width:'+pct+'%;background:'+color+'"></div></div>'
+ '<div style="display:flex;gap:8px;margin-top:12px">'
+ '<button class="btn-sm" style="background:'+color+';flex:1" onclick="confirmFieldScan(&quot;'+esc(d.value)+'&quot;)">✓ Utiliser</button>'
+ '<button class="btn-sm" style="background:#94a3b8" onclick="captureFieldScan()">↻ Réessayer</button>'
+ '</div>'
+'</div>';
}).catch(function(e){$('fsResult').innerHTML='<div class="sr" style="color:#ef4444">Erreur réseau : '+(e.message||'')+'</div>'});
}
function confirmFieldScan(value){
if(fsCallback)fsCallback(value);
closeFieldScan();
}
</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 }