- 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>
962 lines
51 KiB
JavaScript
962 lines
51 KiB
JavaScript
'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("'+_esc(code)+'");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("'+_esc(it.name)+'","'+_esc(it.barcode||'')+'","'+_esc(it.equipment_type||'')+'")">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("'+_esc(it.item_name||it.name)+'","'+_esc(it.name)+'","'+_esc(it.item_group||'')+'")"><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 }
|