gigafibre-fsm/services/targo-hub/lib/tech-mobile.js
louispaulb 9fda9eb0b0 refactor(targo-hub): add types.js, migrate acceptance+payments, drop apps/field
- lib/types.js: single source of truth for Dispatch Job status + priority enums.
  Eliminates hard-coded 'In Progress'/'in_progress'/'Completed'/'done' checks
  scattered across tech-mobile, acceptance, dispatch. Includes CLIENT_TYPES_JS
  snippet for embedding in SSR <script> blocks (no require() needed).

- lib/tech-mobile.js: applies types.js predicates (isInProgress, isTerminal,
  isDone, isUrgent) both server-side and client-side via ${CLIENT_TYPES_JS}
  template injection. Single aliasing point for future status renames.

- lib/acceptance.js: migrated 7 erpFetch + 2 erpRequest sites to erp.js wrapper.
  Removed duplicate "Lien expiré" HTML (now ui.pageExpired()). Dispatch Job
  creation uses types.JOB_STATUS + types.JOB_PRIORITY.

- lib/payments.js: migrated 15 erpFetch + 9 erpRequest sites to erp.js wrapper.
  Live Stripe flows preserved exactly — frappe.client.submit calls kept as
  erp.raw passthroughs (fetch-full-doc-then-submit pattern intact). Includes
  refund → Return PE → Credit Note lifecycle, PPA cron, idempotency guard.

- apps/field/ deleted: transitional Quasar PWA fully retired in favor of
  SSR tech-mobile at /t/{jwt}. Saves 14k lines of JS, PWA icons, and
  infra config. Docs already marked it "retiring".

Smoke-tested on prod:
  /payments/balance/:customer (200, proper shape)
  /payments/methods/:customer (200, Stripe cards live-fetched)
  /dispatch/calendar/:tech.ics (200, VCALENDAR)
  /t/{jwt} (55KB render, no errors)

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

962 lines
51 KiB
JavaScript
Raw Permalink 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 types = require('./types')
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 = types.isUrgent(j.priority)
const done = types.isTerminal(j.status)
const inProg = types.isInProgress(j.status)
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 => types.isInProgress(j.status))
const pending = jobs.filter(j => !types.isTerminal(j.status) && !types.isInProgress(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 => types.isTerminal(j.status))
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 => types.isDone(j.status)).length
const cancCount = history.filter(j => types.isCancelled(j.status)).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 = `
${types.CLIENT_TYPES_JS}
// 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 = isDone(j.status);
else if(f==='cancelled') okF = j.status==='Cancelled';
else if(f==='overdue') okF = !isTerminal(j.status) && 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 = isTerminal(j.status);
var canStart = ['Scheduled','assigned','open'].indexOf(j.status) >= 0;
var canFinish= isInProgress(j.status);
var sMeta = {Scheduled:['Planifié','#818cf8'], assigned:['Assigné','#818cf8'], open:['Ouvert','#818cf8'],
'In Progress':['En cours','#f59e0b'], in_progress:['En cours','#f59e0b'],
Completed:['Terminé','#22c55e'], done:['Terminé','#22c55e'], Cancelled:['Annulé','#94a3b8']};
var sm = sMeta[j.status] || [j.status||'—','#94a3b8'];
var urgent = isUrgent(j.priority);
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 }