Rewrote msg.gigafibre.ca (tech magic-link page) from a today-only flat list
into a proper 4-tab SPA:
- Aujourd'hui: In Progress / En retard / Aujourd'hui / Sans date / À venir
- Calendrier: placeholder (phase 4)
- Historique: searchable + filter chips (Tous/Terminés/Manqués/Annulés)
- Profil: tech info, support line, refresh
Job detail view (hash-routed, #job/DJ-xxx):
- Customer + tap-to-call/navigate block
- Editable notes (textarea → PUT /api/resource/Dispatch Job)
- Photo upload (base64 → File doctype, is_private, proxied back via /photo-serve)
- Equipment section (inherited from overlay)
- Sticky action bar (Démarrer / Terminer)
Equipment overlay extended with per-field Gemini Vision scanners. Each
input (SN, MAC, GPON SN, Wi-Fi SSID, Wi-Fi PWD, model) has a 📷 that opens
a capture modal; Gemini is prompted to find THAT field specifically and
returns value+confidence. Tech confirms or retries before the value fills in.
Root cause of the "tech can't see his job" bug: page filtered
scheduled_date=today, so jobs on any other day were invisible even though
the token was tech-scoped. Now fetches a ±60d window and groups client-side.
vision.js: new extractField(base64, field, ctx) helper + handleFieldScan
route (used by new /t/:token/field-scan endpoint).
Also fixes discovered along the way:
- Frappe v16 blocks fetched/linked fields (customer_name, service_location_name)
and phantom fields (scheduled_time — real one is start_time). Query now
uses only own fields; names resolved in two batch follow-up queries.
- "Today" is Montreal-local, not UTC. Prevents evening jobs being mislabeled
as "hier" when UTC has already rolled to the next day.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1196 lines
70 KiB
JavaScript
1196 lines
70 KiB
JavaScript
'use strict'
|
||
const cfg = require('./config')
|
||
const { log, json, erpFetch } = require('./helpers')
|
||
const { verifyJwt } = require('./magic-link')
|
||
const { extractField } = require('./vision')
|
||
|
||
// ── Tech mobile SPA ──────────────────────────────────────────────────────────
|
||
// Server-rendered shell + hash-routed client views (home/hist/cal/profile/job-detail).
|
||
// No framework. All jobs pre-loaded and embedded as JSON; detail view pops open
|
||
// instantly from the cache and async-refreshes notes / photos / equipment.
|
||
//
|
||
// Field-targeted scans: each form field has a 📷 that opens a Gemini-backed
|
||
// single-field extractor — "find the Wi-Fi password on this label" rather than
|
||
// "read everything". Tech approves or retries the value before it fills the input.
|
||
|
||
// ── Auth helpers ─────────────────────────────────────────────────────────────
|
||
|
||
function authToken (path) {
|
||
const m = path.match(/^\/t\/([A-Za-z0-9_\-\.]+)/)
|
||
if (!m) return null
|
||
return verifyJwt(m[1])
|
||
}
|
||
|
||
async function readBody (req) {
|
||
const chunks = []
|
||
for await (const c of req) chunks.push(c)
|
||
try { return JSON.parse(Buffer.concat(chunks).toString()) } catch { return null }
|
||
}
|
||
|
||
// YYYY-MM-DD in America/Montreal regardless of server TZ
|
||
function montrealDate (d = new Date()) {
|
||
return d.toLocaleDateString('en-CA', { timeZone: 'America/Montreal', year: 'numeric', month: '2-digit', day: '2-digit' })
|
||
}
|
||
|
||
// ── Route dispatcher ─────────────────────────────────────────────────────────
|
||
|
||
async function route (req, res, method, path) {
|
||
// GET /t/{token} → page shell (server-rendered, all jobs embedded)
|
||
if (method === 'GET' && /^\/t\/[A-Za-z0-9_\-\.]+$/.test(path)) return handlePage(req, res, path)
|
||
// POST /t/{token}/status → set status + walk chain
|
||
if (method === 'POST' && path.endsWith('/status')) return handleStatus(req, res, path)
|
||
// POST /t/{token}/note → save notes on a Dispatch Job
|
||
if (method === 'POST' && path.endsWith('/note')) return handleNote(req, res, path)
|
||
// POST /t/{token}/photo → upload photo (base64) attached to a job
|
||
if (method === 'POST' && path.endsWith('/photo')) return handlePhotoUpload(req, res, path)
|
||
// GET /t/{token}/photos?job= → list photos on a job
|
||
if (method === 'GET' && path.endsWith('/photos')) return handlePhotoList(req, res, path)
|
||
// GET /t/{token}/photo-serve?p=/private/files/... → proxy a private file
|
||
if (method === 'GET' && path.endsWith('/photo-serve')) return handlePhotoServe(req, res, path)
|
||
// GET /t/{token}/job?name= → refresh a single job
|
||
if (method === 'GET' && path.endsWith('/job')) return handleJobDetail(req, res, path)
|
||
// POST /t/{token}/field-scan → Gemini single-field extraction (SN, MAC, SSID, etc)
|
||
if (method === 'POST' && path.endsWith('/field-scan')) return handleFieldScan(req, res, path)
|
||
// POST /t/{token}/scan → known-code lookup in Service Equipment / Serial No
|
||
if (method === 'POST' && path.endsWith('/scan')) return handleScan(req, res, path)
|
||
// POST /t/{token}/vision → generic multi-barcode extraction
|
||
if (method === 'POST' && path.endsWith('/vision')) return handleVision(req, res, path)
|
||
// POST /t/{token}/equip → create Equipment Install + Service Equipment
|
||
if (method === 'POST' && path.endsWith('/equip')) return handleEquipInstall(req, res, path)
|
||
// POST /t/{token}/equip-remove
|
||
if (method === 'POST' && path.endsWith('/equip-remove')) return handleEquipRemove(req, res, path)
|
||
// GET /t/{token}/catalog
|
||
if (method === 'GET' && path.endsWith('/catalog')) return handleCatalog(req, res, path)
|
||
// GET /t/{token}/equip-list?job=
|
||
if (method === 'GET' && path.endsWith('/equip-list')) return handleEquipList(req, res, path)
|
||
return json(res, 404, { error: 'Not found' })
|
||
}
|
||
|
||
// ── Shell + data fetch ───────────────────────────────────────────────────────
|
||
|
||
async function handlePage (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) { res.writeHead(200, html()); return res.end(pageExpired()) }
|
||
|
||
const techId = payload.sub
|
||
// Use Montreal local time for "today" — UTC midnight rolls over 4-5 hours
|
||
// before Montreal midnight, which would mislabel evening jobs as "hier".
|
||
const today = montrealDate(new Date())
|
||
const rearDate = montrealDate(new Date(Date.now() - 60 * 86400 * 1000))
|
||
const frontDate = montrealDate(new Date(Date.now() + 60 * 86400 * 1000))
|
||
|
||
try {
|
||
const techRes = await erpFetch(`/api/resource/Dispatch%20Technician/${encodeURIComponent(techId)}?fields=${encodeURIComponent(JSON.stringify(['name', 'full_name', 'phone', 'email', 'assigned_group']))}`)
|
||
const tech = techRes.status === 200 && techRes.data?.data ? techRes.data.data : { name: techId, full_name: techId }
|
||
|
||
// Frappe v16 blocks fetched/linked fields (customer_name, service_location_name)
|
||
// AND blocks fields not in the doctype DocField list. Real time field is
|
||
// `start_time` — `scheduled_time` was a phantom field that v15 silently
|
||
// ignored. We fetch link IDs and resolve names separately.
|
||
const fields = JSON.stringify(['name', 'subject', 'status', 'customer', 'service_location', 'start_time', 'scheduled_date', 'duration_h', 'priority', 'job_type', 'notes', 'assigned_group', 'address', 'ticket_id', 'source_issue', 'actual_start', 'actual_end'])
|
||
const filters = JSON.stringify([
|
||
['assigned_tech', '=', techId],
|
||
['scheduled_date', 'between', [rearDate, frontDate]],
|
||
])
|
||
const jobsRes = await erpFetch(`/api/resource/Dispatch%20Job?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&order_by=scheduled_date+desc,start_time+asc&limit_page_length=300`)
|
||
const jobs = jobsRes.status === 200 && Array.isArray(jobsRes.data?.data) ? jobsRes.data.data : []
|
||
|
||
// Resolve customer + service_location labels in two batch queries
|
||
const custIds = [...new Set(jobs.map(j => j.customer).filter(Boolean))]
|
||
const locIds = [...new Set(jobs.map(j => j.service_location).filter(Boolean))]
|
||
const custNames = {}
|
||
const locNames = {}
|
||
if (custIds.length) {
|
||
try {
|
||
const cf = encodeURIComponent(JSON.stringify([['name', 'in', custIds]]))
|
||
const cr = await erpFetch(`/api/resource/Customer?filters=${cf}&fields=${encodeURIComponent(JSON.stringify(['name', 'customer_name']))}&limit_page_length=200`)
|
||
for (const c of (cr.data?.data || [])) custNames[c.name] = c.customer_name || c.name
|
||
} catch (_) { /* non-fatal */ }
|
||
}
|
||
if (locIds.length) {
|
||
try {
|
||
const lf = encodeURIComponent(JSON.stringify([['name', 'in', locIds]]))
|
||
const lr = await erpFetch(`/api/resource/Service%20Location?filters=${lf}&fields=${encodeURIComponent(JSON.stringify(['name', 'address_line_1', 'city']))}&limit_page_length=200`)
|
||
for (const l of (lr.data?.data || [])) locNames[l.name] = [l.address_line_1, l.city].filter(Boolean).join(', ') || l.name
|
||
} catch (_) { /* non-fatal */ }
|
||
}
|
||
for (const j of jobs) {
|
||
j.customer_name = custNames[j.customer] || j.customer || ''
|
||
j.service_location_name = locNames[j.service_location] || j.service_location || ''
|
||
}
|
||
|
||
res.writeHead(200, html())
|
||
res.end(renderPage({ tech, jobs, token: path.split('/')[2], today }))
|
||
} catch (e) {
|
||
log('tech-mobile page error:', e.message)
|
||
res.writeHead(500, html())
|
||
res.end(pageError())
|
||
}
|
||
}
|
||
|
||
// ── Status update (reuse dispatch chain walker) ──────────────────────────────
|
||
|
||
async function handleStatus (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const body = await readBody(req)
|
||
if (!body?.job || !body?.status) return json(res, 400, { error: 'job and status required' })
|
||
const allowed = ['In Progress', 'Completed']
|
||
if (!allowed.includes(body.status)) return json(res, 400, { error: 'Invalid status' })
|
||
try {
|
||
const { setJobStatusWithChain } = require('./dispatch')
|
||
const result = await setJobStatusWithChain(body.job, body.status)
|
||
return json(res, 200, { ...result, tech: payload.sub })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
// ── Notes save ───────────────────────────────────────────────────────────────
|
||
|
||
async function handleNote (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const body = await readBody(req)
|
||
if (!body?.job) return json(res, 400, { error: 'job required' })
|
||
try {
|
||
const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(body.job)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ notes: body.notes || '' }),
|
||
})
|
||
if (r.status >= 400) return json(res, r.status, { error: r.data?.exception || r.data?._error_message || 'save failed' })
|
||
return json(res, 200, { ok: true })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
// ── Photo upload (base64 → File doc, attached to job) ───────────────────────
|
||
|
||
async function handlePhotoUpload (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const body = await readBody(req)
|
||
if (!body?.job || !body?.image) return json(res, 400, { error: 'job and image required' })
|
||
try {
|
||
const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '')
|
||
const ext = (body.image.match(/^data:image\/([a-z]+);/) || [, 'jpg'])[1].replace('jpeg', 'jpg')
|
||
const fileName = body.file_name || `dj-${body.job}-${Date.now()}.${ext}`
|
||
const r = await erpFetch('/api/resource/File', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
file_name: fileName,
|
||
is_private: 1,
|
||
content: base64,
|
||
decode: 1,
|
||
attached_to_doctype: 'Dispatch Job',
|
||
attached_to_name: body.job,
|
||
}),
|
||
})
|
||
if (r.status >= 400) return json(res, r.status, { error: r.data?.exception || r.data?._error_message || 'upload failed' })
|
||
return json(res, 200, { ok: true, name: r.data?.data?.name, file_url: r.data?.data?.file_url, file_name: r.data?.data?.file_name })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
async function handlePhotoList (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const url = new URL(req.url, 'http://localhost')
|
||
const jobName = url.searchParams.get('job')
|
||
if (!jobName) return json(res, 400, { error: 'job required' })
|
||
try {
|
||
const f = encodeURIComponent(JSON.stringify([
|
||
['attached_to_doctype', '=', 'Dispatch Job'],
|
||
['attached_to_name', '=', jobName],
|
||
['file_name', 'like', 'dj-%'],
|
||
]))
|
||
const r = await erpFetch(`/api/resource/File?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'file_name', 'file_url', 'is_private', 'creation']))}&order_by=creation+desc&limit_page_length=50`)
|
||
const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : []
|
||
return json(res, 200, { ok: true, items })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
// Proxy a private file from ERPNext so the tech browser can render it without
|
||
// carrying ERPNext credentials. Query param `p` must be a /private/files/ path.
|
||
async function handlePhotoServe (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) { res.writeHead(401); return res.end() }
|
||
const url = new URL(req.url, 'http://localhost')
|
||
const filePath = url.searchParams.get('p')
|
||
if (!filePath || !/^\/(private\/files|files)\//.test(filePath)) { res.writeHead(400); return res.end() }
|
||
try {
|
||
const erpBase = (cfg.ERPNEXT_URL || 'https://erp.gigafibre.ca').replace(/\/$/, '')
|
||
const headers = {}
|
||
if (cfg.ERPNEXT_API_KEY && cfg.ERPNEXT_API_SECRET) {
|
||
headers.Authorization = `token ${cfg.ERPNEXT_API_KEY}:${cfg.ERPNEXT_API_SECRET}`
|
||
}
|
||
const r = await fetch(erpBase + filePath, { headers })
|
||
if (!r.ok) { res.writeHead(404); return res.end() }
|
||
const ct = r.headers.get('content-type') || 'application/octet-stream'
|
||
const buf = Buffer.from(await r.arrayBuffer())
|
||
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'private, max-age=300' })
|
||
res.end(buf)
|
||
} catch (e) {
|
||
log('photo-serve error:', e.message)
|
||
res.writeHead(500); res.end()
|
||
}
|
||
}
|
||
|
||
// ── Single-job refresh (detail view uses embedded data, but can refresh) ─────
|
||
|
||
async function handleJobDetail (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const url = new URL(req.url, 'http://localhost')
|
||
const name = url.searchParams.get('name')
|
||
if (!name) return json(res, 400, { error: 'name required' })
|
||
try {
|
||
const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(name)}`)
|
||
if (r.status !== 200 || !r.data?.data) return json(res, 404, { error: 'not found' })
|
||
return json(res, 200, { ok: true, job: r.data.data })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
// ── Gemini field-scan ────────────────────────────────────────────────────────
|
||
|
||
async function handleFieldScan (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const body = await readBody(req)
|
||
if (!body?.image || !body?.field) return json(res, 400, { error: 'image and field required' })
|
||
try {
|
||
const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '')
|
||
const out = await extractField(base64, body.field, {
|
||
hint: body.hint,
|
||
equipment_type: body.equipment_type,
|
||
brand: body.brand,
|
||
model: body.model,
|
||
})
|
||
return json(res, 200, { ok: true, ...out })
|
||
} catch (e) {
|
||
log('field-scan error:', e.message)
|
||
return json(res, 500, { error: e.message })
|
||
}
|
||
}
|
||
|
||
// ── Barcode lookup in ERPNext ───────────────────────────────────────────────
|
||
|
||
async function handleScan (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const body = await readBody(req)
|
||
if (!body?.code) return json(res, 400, { error: 'code required' })
|
||
const q = body.code.replace(/[:\-\s]/g, '').toUpperCase()
|
||
try {
|
||
for (const field of ['serial_number', 'mac_address']) {
|
||
const f = encodeURIComponent(JSON.stringify([[field, 'like', `%${q}%`]]))
|
||
const r = await erpFetch(`/api/resource/Service%20Equipment?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'serial_number', 'item_name', 'equipment_type', 'mac_address', 'status', 'customer', 'service_location']))}&limit_page_length=5`)
|
||
if (r.status === 200 && r.data?.data?.length) return json(res, 200, { ok: true, results: r.data.data, source: 'Service Equipment', query: q })
|
||
}
|
||
const f = encodeURIComponent(JSON.stringify([['serial_no', 'like', `%${q}%`]]))
|
||
const r = await erpFetch(`/api/resource/Serial%20No?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'serial_no', 'item_code', 'status', 'warehouse']))}&limit_page_length=5`)
|
||
if (r.status === 200 && r.data?.data?.length) return json(res, 200, { ok: true, results: r.data.data, source: 'Serial No', query: q })
|
||
return json(res, 200, { ok: true, results: [], query: q })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
async function handleVision (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const body = await readBody(req)
|
||
if (!body?.image) return json(res, 400, { error: 'image required' })
|
||
try {
|
||
const { extractBarcodes } = require('./vision')
|
||
const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '')
|
||
const result = await extractBarcodes(base64)
|
||
return json(res, 200, { ok: true, ...result })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
// ── Equipment Install / Remove / Catalog ────────────────────────────────────
|
||
|
||
async function handleEquipInstall (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const body = await readBody(req)
|
||
if (!body) return json(res, 400, { error: 'body required' })
|
||
|
||
const {
|
||
job, barcode, mac_address, gpon_sn, equipment_type, brand, model,
|
||
wifi_ssid, wifi_password, notes, customer, location, action,
|
||
} = body
|
||
const today = new Date().toISOString().slice(0, 10)
|
||
|
||
try {
|
||
// 1. Equipment Install log
|
||
const installData = {
|
||
request: job || '',
|
||
barcode: barcode || '',
|
||
equipment_type: equipment_type || '',
|
||
brand: brand || '',
|
||
model: model || '',
|
||
notes: [
|
||
notes || '',
|
||
mac_address ? `MAC: ${mac_address}` : '',
|
||
gpon_sn ? `GPON-SN: ${gpon_sn}` : '',
|
||
wifi_ssid ? `SSID: ${wifi_ssid}` : '',
|
||
wifi_password ? `Wi-Fi PWD: ${wifi_password}` : '',
|
||
].filter(Boolean).join(' | '),
|
||
installation_date: today,
|
||
technician: payload.sub,
|
||
action: action || 'install',
|
||
}
|
||
const r = await erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify(installData) })
|
||
|
||
// 2. Link/create Service Equipment when installing/replacing
|
||
if (barcode && (action === 'install' || action === 'replace')) {
|
||
try {
|
||
const q = barcode.replace(/[:\-\s]/g, '').toUpperCase()
|
||
const ef = encodeURIComponent(JSON.stringify([['serial_number', 'like', `%${q}%`]]))
|
||
const existing = await erpFetch(`/api/resource/Service%20Equipment?filters=${ef}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1`)
|
||
const seCommon = {
|
||
status: 'Actif',
|
||
customer: customer || '',
|
||
service_location: location || '',
|
||
mac_address: mac_address || '',
|
||
gpon_sn: gpon_sn || '',
|
||
wifi_ssid: wifi_ssid || '',
|
||
wifi_password: wifi_password || '',
|
||
brand: brand || '',
|
||
model: model || '',
|
||
equipment_type: equipment_type || '',
|
||
}
|
||
// Drop undefined/empty string keys so we don't wipe existing data on update
|
||
const seUpdate = Object.fromEntries(Object.entries(seCommon).filter(([, v]) => v !== '' && v != null))
|
||
if (existing.status === 200 && existing.data?.data?.length) {
|
||
await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(existing.data.data[0].name)}`, {
|
||
method: 'PUT', body: JSON.stringify(seUpdate),
|
||
})
|
||
} else {
|
||
await erpFetch('/api/resource/Service%20Equipment', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ serial_number: barcode, ...seCommon }),
|
||
})
|
||
}
|
||
} catch (e) { log('Equip link/create warn:', e.message) }
|
||
}
|
||
return json(res, 200, { ok: true, name: r.data?.data?.name || '' })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
async function handleEquipRemove (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const body = await readBody(req)
|
||
if (!body?.equipment) return json(res, 400, { error: 'equipment name required' })
|
||
try {
|
||
await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(body.equipment)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ status: body.status || 'Retourné', customer: '', service_location: '' }),
|
||
})
|
||
await erpFetch('/api/resource/Equipment%20Install', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
request: body.job || '', barcode: body.serial || '',
|
||
equipment_type: body.equipment_type || '',
|
||
notes: `Retrait par ${payload.sub}`,
|
||
installation_date: new Date().toISOString().slice(0, 10),
|
||
technician: payload.sub, action: 'remove',
|
||
}),
|
||
})
|
||
return json(res, 200, { ok: true })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
async function handleCatalog (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
try {
|
||
const groups = encodeURIComponent(JSON.stringify([['item_group', 'in', ['Network Equipment', 'CPE', 'Équipements réseau', 'Products']]]))
|
||
const r = await erpFetch(`/api/resource/Item?filters=${groups}&fields=${encodeURIComponent(JSON.stringify(['name', 'item_name', 'item_group', 'standard_rate', 'image', 'description']))}&limit_page_length=50`)
|
||
const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : []
|
||
if (!items.length) {
|
||
return json(res, 200, { ok: true, items: [
|
||
{ name: 'ONT-GPON', item_name: 'ONT GPON', item_group: 'CPE', standard_rate: 0, description: 'Terminal fibre client' },
|
||
{ name: 'ROUTER-WIFI6', item_name: 'Routeur Wi-Fi 6', item_group: 'CPE', standard_rate: 9.99, description: 'Routeur sans fil' },
|
||
{ name: 'DECODER-IPTV', item_name: 'Décodeur IPTV', item_group: 'CPE', standard_rate: 7.99, description: 'Décodeur télévision' },
|
||
{ name: 'VOIP-ATA', item_name: 'Adaptateur VoIP', item_group: 'CPE', standard_rate: 4.99, description: 'Adaptateur téléphonie' },
|
||
{ name: 'AMP-COAX', item_name: 'Amplificateur coaxial', item_group: 'Network Equipment', standard_rate: 0, description: 'Amplificateur signal' },
|
||
]})
|
||
}
|
||
return json(res, 200, { ok: true, items })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
async function handleEquipList (req, res, path) {
|
||
const payload = authToken(path)
|
||
if (!payload) return json(res, 401, { error: 'expired' })
|
||
const url = new URL(req.url, 'http://localhost')
|
||
const jobName = url.searchParams.get('job')
|
||
if (!jobName) return json(res, 400, { error: 'job param required' })
|
||
try {
|
||
const f = encodeURIComponent(JSON.stringify([['request', '=', jobName]]))
|
||
const r = await erpFetch(`/api/resource/Equipment%20Install?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'barcode', 'equipment_type', 'brand', 'model', 'action', 'notes', 'installation_date']))}&order_by=creation+desc&limit_page_length=20`)
|
||
const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : []
|
||
return json(res, 200, { ok: true, items })
|
||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// HTML
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
function html () { return { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store' } }
|
||
function esc (s) { return (s == null ? '' : String(s)).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''') }
|
||
function fmtTime (t) { if (!t) return ''; const [h, m] = String(t).split(':'); return `${h}h${m || '00'}` }
|
||
function fmtDate (d) {
|
||
if (!d) return ''
|
||
try { return new Date(d + 'T00:00:00').toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' }) }
|
||
catch { return d }
|
||
}
|
||
function dateLabelFr (d, today) {
|
||
if (!d) return 'Sans date'
|
||
if (d === today) return "Aujourd'hui"
|
||
try {
|
||
const diff = Math.round((new Date(d + 'T00:00:00') - new Date(today + 'T00:00:00')) / 86400000)
|
||
if (diff === -1) return 'Hier'
|
||
if (diff === 1) return 'Demain'
|
||
if (diff > 1 && diff <= 6) return new Date(d + 'T00:00:00').toLocaleDateString('fr-CA', { weekday: 'long' })
|
||
return fmtDate(d)
|
||
} catch { return fmtDate(d) }
|
||
}
|
||
function todayFr () { return new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' }) }
|
||
|
||
const STATUS_META = {
|
||
Scheduled: { label: 'Planifié', color: '#818cf8' },
|
||
assigned: { label: 'Assigné', color: '#818cf8' },
|
||
open: { label: 'Ouvert', color: '#818cf8' },
|
||
'In Progress':{ label: 'En cours', color: '#f59e0b' },
|
||
in_progress: { label: 'En cours', color: '#f59e0b' },
|
||
Completed: { label: 'Terminé', color: '#22c55e' },
|
||
Cancelled: { label: 'Annulé', color: '#94a3b8' },
|
||
}
|
||
function badge (s) {
|
||
const m = STATUS_META[s] || { label: s || '—', color: '#94a3b8' }
|
||
return `<span class="bdg" style="background:${m.color}22;color:${m.color}">${esc(m.label)}</span>`
|
||
}
|
||
|
||
function jobCard (j, today) {
|
||
const urgent = j.priority === 'urgent' || j.priority === 'high'
|
||
const done = j.status === 'Completed' || j.status === 'Cancelled'
|
||
const border = urgent ? '#ef4444' : done ? '#94a3b8' : j.status === 'In Progress' || j.status === 'in_progress' ? '#f59e0b' : '#5c59a8'
|
||
const overdue = !done && j.scheduled_date && j.scheduled_date < today
|
||
const dlbl = dateLabelFr(j.scheduled_date, today)
|
||
const tlbl = j.start_time ? fmtTime(j.start_time) : ''
|
||
const datePart = [dlbl, tlbl].filter(Boolean).join(' · ')
|
||
return `<div class="card${done ? ' dim' : ''}${overdue ? ' od' : ''}" onclick="go('#job/${esc(j.name)}')" style="border-left:4px solid ${border}">
|
||
<div class="row"><span class="jid">${esc(j.name)}</span>${badge(j.status)}<span class="jdt">${esc(datePart)}</span></div>
|
||
<div class="jtt">${esc(j.subject || 'Sans titre')}</div>
|
||
${j.customer_name ? `<div class="jsb">👤 ${esc(j.customer_name)}</div>` : ''}
|
||
${j.service_location_name ? `<div class="jsb">📍 ${esc(j.service_location_name)}</div>` : ''}
|
||
<div class="jmt">${j.duration_h ? `⏱ ${j.duration_h}h` : ''} ${j.job_type ? esc(j.job_type) : ''}${urgent ? ' 🔥' : ''}</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderPage ({ tech, jobs, token, today }) {
|
||
const techName = tech.full_name || tech.name
|
||
// Partition for the home view — rest is client-side filtering
|
||
const inProgress = jobs.filter(j => j.status === 'In Progress' || j.status === 'in_progress')
|
||
const pending = jobs.filter(j => !['Completed', 'Cancelled', 'In Progress', 'in_progress'].includes(j.status))
|
||
const overdue = pending.filter(j => j.scheduled_date && j.scheduled_date < today)
|
||
const todayJobs = pending.filter(j => j.scheduled_date === today)
|
||
const upcoming = pending.filter(j => j.scheduled_date && j.scheduled_date > today).slice(0, 20)
|
||
const history = jobs.filter(j => j.status === 'Completed' || j.status === 'Cancelled')
|
||
const nodate = pending.filter(j => !j.scheduled_date)
|
||
const activeCount = inProgress.length + overdue.length + todayJobs.length + nodate.length
|
||
const hub = cfg.EXTERNAL_URL || ''
|
||
// Jobs embedded as a lookup table by name — detail view reads from here
|
||
const jobsMap = {}
|
||
for (const j of jobs) jobsMap[j.name] = j
|
||
const jobsJson = JSON.stringify(jobsMap).replace(/</g, '\\u003c').replace(/-->/g, '--\\u003e')
|
||
|
||
const sec = (title, list, mod = '') => list.length
|
||
? `<div class="sec${mod ? ' ' + mod : ''}">${title} <span class="cnt">${list.length}</span></div>${list.map(j => jobCard(j, today)).join('')}`
|
||
: ''
|
||
|
||
return `<!DOCTYPE html><html lang="fr"><head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||
<meta name="theme-color" content="#3f3d7a"><title>Mes tâches — Gigafibre</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;color:#0f172a;-webkit-text-size-adjust:100%;padding-bottom:68px;min-height:100vh}
|
||
.hdr{background:linear-gradient(135deg,#3f3d7a,#5c59a8);color:#fff;padding:14px 16px 28px;border-radius:0 0 18px 18px;position:relative;z-index:1}
|
||
.hdr-d{font-size:12px;opacity:.75;text-transform:capitalize}
|
||
.hdr-n{font-size:19px;font-weight:700;margin:2px 0 12px;display:flex;align-items:center;gap:8px}
|
||
.hdr-n .ic{width:32px;height:32px;border-radius:50%;background:rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;font-size:16px}
|
||
.sts{display:flex;gap:8px}
|
||
.st{flex:1;background:rgba(255,255,255,.14);border-radius:10px;padding:10px 4px;text-align:center;cursor:pointer;transition:background .15s}
|
||
.st:active{background:rgba(255,255,255,.24)}
|
||
.st b{display:block;font-size:19px;font-weight:700}.st small{font-size:10px;opacity:.75;letter-spacing:.3px}
|
||
.wrap{padding:14px 12px;margin-top:-14px}
|
||
.view{display:none}.view.active{display:block}
|
||
.sec{font-size:11px;font-weight:700;color:#5c59a8;letter-spacing:1.2px;margin:16px 4px 8px;text-transform:uppercase;display:flex;align-items:center;gap:8px}
|
||
.sec .cnt{background:#ddd8ff;color:#5c59a8;padding:1px 7px;border-radius:10px;font-size:10px}
|
||
.sec.danger{color:#ef4444}.sec.danger .cnt{background:#fee2e2;color:#ef4444}
|
||
.card{background:#fff;border-radius:12px;padding:12px;margin-bottom:10px;box-shadow:0 1px 2px rgba(15,23,42,.06);cursor:pointer;transition:transform .1s}
|
||
.card:active{transform:scale(.985)}
|
||
.card.dim{opacity:.6}.card.od{box-shadow:0 1px 2px rgba(239,68,68,.15),0 0 0 1px #fecaca}
|
||
.row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
.jid{font-size:10px;font-weight:700;color:#5c59a8;letter-spacing:.3px}
|
||
.jdt{margin-left:auto;font-size:11px;color:#64748b;font-weight:600;text-align:right;text-transform:capitalize}
|
||
.bdg{padding:2px 8px;border-radius:8px;font-size:10px;font-weight:700;letter-spacing:.2px}
|
||
.jtt{font-size:15px;font-weight:600;margin:6px 0 2px;color:#0f172a;line-height:1.25}
|
||
.jsb{font-size:12px;color:#64748b;margin:1px 0}
|
||
.jmt{font-size:11px;color:#94a3b8;margin:4px 0 0}
|
||
.empty{text-align:center;padding:50px 24px;color:#94a3b8}
|
||
.empty .em{font-size:44px;margin-bottom:8px;opacity:.5}
|
||
/* Search bar */
|
||
.sbar{position:sticky;top:0;background:#f1f5f9;padding:10px 4px 8px;z-index:5;margin:0 -4px}
|
||
.sinp{width:100%;padding:10px 14px;border:1.5px solid #e2e8f0;border-radius:12px;font-size:14px;background:#fff;outline:none}
|
||
.sinp:focus{border-color:#5c59a8}
|
||
/* Tab bar */
|
||
.tbar{position:fixed;bottom:0;left:0;right:0;background:#fff;border-top:1px solid #e2e8f0;display:flex;height:62px;z-index:40;padding-bottom:env(safe-area-inset-bottom,0)}
|
||
.tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;color:#94a3b8;font-size:10px;font-weight:600;cursor:pointer;border:none;background:none}
|
||
.tab .ic{font-size:22px;line-height:1}
|
||
.tab.on{color:#5c59a8}
|
||
/* Toast */
|
||
.toast{position:fixed;top:14px;left:12px;right:12px;padding:12px 14px;border-radius:10px;color:#fff;font-size:13px;font-weight:600;text-align:center;z-index:300;opacity:0;transform:translateY(-12px);transition:all .25s;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,.2)}
|
||
.toast.on{opacity:1;transform:translateY(0)}
|
||
/* Detail view */
|
||
#view-detail{background:#f1f5f9}
|
||
.dt-hdr{background:linear-gradient(135deg,#3f3d7a,#5c59a8);color:#fff;padding:12px 14px 24px;border-radius:0 0 18px 18px}
|
||
.dt-hdr .nav{display:flex;align-items:center;gap:10px;margin-bottom:10px}
|
||
.dt-hdr .nav button{background:rgba(255,255,255,.18);border:none;color:#fff;width:36px;height:36px;border-radius:50%;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||
.dt-hdr .nav .jn{flex:1;font-size:11px;font-weight:700;opacity:.8;letter-spacing:.5px}
|
||
.dt-hdr h1{font-size:19px;font-weight:700;margin-bottom:6px;line-height:1.25}
|
||
.dt-hdr .meta{display:flex;gap:8px;flex-wrap:wrap;font-size:12px;opacity:.9}
|
||
.dt-hdr .meta span{background:rgba(255,255,255,.18);padding:3px 10px;border-radius:8px}
|
||
.panel{background:#fff;border-radius:12px;padding:14px;margin:12px 12px 0;box-shadow:0 1px 2px rgba(15,23,42,.06)}
|
||
.panel h3{font-size:11px;font-weight:700;color:#5c59a8;letter-spacing:1.2px;margin-bottom:8px;text-transform:uppercase;display:flex;align-items:center;gap:8px}
|
||
.panel h3 .rt{margin-left:auto;font-size:10px;color:#94a3b8;font-weight:600;letter-spacing:.5px}
|
||
.cust-line{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid #f1f5f9;font-size:14px;color:#334155}
|
||
.cust-line:last-child{border-bottom:none}.cust-line .ico{font-size:18px;width:24px;text-align:center}
|
||
.cust-line a{color:#5c59a8;margin-left:auto;text-decoration:none;font-weight:600;font-size:13px;padding:6px 10px;background:#eef2ff;border-radius:8px}
|
||
.desc{font-size:14px;color:#334155;line-height:1.5;white-space:pre-wrap}
|
||
.notes-area{width:100%;min-height:120px;padding:10px 12px;border:1.5px solid #e2e8f0;border-radius:10px;font-size:14px;font-family:inherit;resize:vertical;outline:none;line-height:1.4}
|
||
.notes-area:focus{border-color:#5c59a8}
|
||
.savebtn{margin-top:8px;padding:10px 16px;border:none;border-radius:10px;background:#5c59a8;color:#fff;font-weight:600;font-size:14px;cursor:pointer;width:100%}
|
||
.savebtn:active{background:#3f3d7a}.savebtn:disabled{opacity:.5}
|
||
.photo-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:4px}
|
||
.photo-grid .ph{position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:#f1f5f9}
|
||
.photo-grid .ph img{width:100%;height:100%;object-fit:cover;display:block}
|
||
.photo-add{aspect-ratio:1;border:2px dashed #cbd5e1;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:26px;color:#94a3b8;cursor:pointer;background:#f8fafc}
|
||
.photo-add:active{background:#eef2ff;border-color:#5c59a8;color:#5c59a8}
|
||
.eq-item{background:#f8fafc;border-radius:10px;padding:10px 12px;margin-bottom:6px;display:flex;align-items:center;gap:10px}
|
||
.eq-item .eq-info{flex:1}.eq-sn{font-weight:700;color:#0f172a;font-size:13px}.eq-type{font-size:11px;color:#64748b;margin-top:2px}
|
||
.eq-rm{background:#fef2f2;color:#ef4444;border:none;border-radius:6px;padding:6px 10px;font-size:12px;font-weight:600;cursor:pointer}
|
||
.add-eq-btn{width:100%;padding:14px;background:#eef2ff;color:#5c59a8;border:2px dashed #c7d2fe;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;margin-top:6px}
|
||
.add-eq-btn:active{background:#e0e7ff}
|
||
.action-bar{position:sticky;bottom:68px;left:0;right:0;background:#fff;border-top:1px solid #e2e8f0;padding:10px 12px;display:flex;gap:8px;z-index:30;margin:20px -12px 0}
|
||
.action-bar .btn{flex:1;padding:14px;border:none;border-radius:10px;font-weight:700;font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px}
|
||
.btn-nav{background:#eef2ff;color:#4f46e5}
|
||
.btn-start{background:#5c59a8;color:#fff}.btn-start:active{background:#3f3d7a}
|
||
.btn-finish{background:#22c55e;color:#fff}.btn-finish:active{background:#16a34a}
|
||
.dis{opacity:.5;pointer-events:none}
|
||
/* Equipment overlay */
|
||
.ov{display:none;position:fixed;inset:0;background:#f1f5f9;z-index:100;flex-direction:column;overflow-y:auto}
|
||
.ov.open{display:flex}
|
||
.ov-hdr{display:flex;align-items:center;padding:12px 14px;background:#fff;border-bottom:1px solid #e2e8f0;position:sticky;top:0;z-index:2;gap:8px}
|
||
.ov-hdr h2{font-size:16px;font-weight:700;flex:1;margin:0;color:#0f172a}
|
||
.ov-x{background:none;border:none;font-size:22px;cursor:pointer;padding:4px 8px;color:#64748b;width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:8px}
|
||
.ov-x:active{background:#f1f5f9}
|
||
.ov-body{padding:14px;flex:1}
|
||
.ov-sec{font-size:11px;font-weight:700;color:#5c59a8;letter-spacing:1.2px;margin:16px 0 8px;text-transform:uppercase}
|
||
.cam-wrap{position:relative;width:100%;max-width:400px;margin:0 auto 10px;border-radius:12px;overflow:hidden;background:#000;aspect-ratio:4/3}
|
||
.cam-wrap video,.cam-wrap canvas{width:100%;display:block}
|
||
.cam-overlay{position:absolute;inset:0;pointer-events:none;border:2px solid rgba(92,89,168,.6);border-radius:12px}
|
||
.inp-row{display:flex;gap:8px;margin-bottom:10px}
|
||
.inp{flex:1;padding:10px 12px;border:2px solid #e2e8f0;border-radius:8px;font-size:15px;background:#fff;outline:none}
|
||
.inp:focus{border-color:#5c59a8}
|
||
.btn-sm{padding:10px 14px;border:none;border-radius:8px;background:#5c59a8;color:#fff;font-weight:600;font-size:14px;cursor:pointer}
|
||
.btn-sm:active{background:#3f3d7a}
|
||
.sr{background:#fff;border-radius:10px;padding:10px 12px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.04);font-size:13px;color:#334155}
|
||
.sr b{color:#5c59a8}.sr .sm{font-size:11px;color:#94a3b8}
|
||
.cat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
|
||
.cat-card{background:#fff;border:2px solid #e5e7eb;border-radius:10px;padding:12px;text-align:center;cursor:pointer;transition:all .15s}
|
||
.cat-card:active{border-color:#5c59a8;background:#eef2ff}
|
||
.cat-card .cn{font-size:13px;font-weight:700;color:#1e293b;margin-bottom:2px}
|
||
.cat-card .cd{font-size:10px;color:#94a3b8}
|
||
.cat-card .cp{font-size:13px;font-weight:700;color:#5c59a8;margin-top:4px}
|
||
.af{background:#fff;border-radius:12px;padding:14px;margin-bottom:16px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||
.af-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
|
||
.af-row b{font-size:15px;color:#0f172a}
|
||
.af label{display:block;font-size:11px;font-weight:700;color:#475569;margin:10px 0 4px;letter-spacing:.3px;text-transform:uppercase}
|
||
.af .fwrap{position:relative;display:flex;gap:6px;align-items:stretch}
|
||
.af .fwrap input,.af .fwrap select{flex:1;padding:9px 11px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:14px;background:#fff;outline:none}
|
||
.af .fwrap input:focus,.af .fwrap select:focus{border-color:#5c59a8}
|
||
.af .fscan{width:42px;height:auto;border:none;border-radius:8px;background:#eef2ff;color:#5c59a8;font-size:18px;cursor:pointer;flex-shrink:0}
|
||
.af .fscan:active{background:#e0e7ff}
|
||
.btn-add-full{width:100%;padding:14px;border:none;border-radius:10px;background:#5c59a8;color:#fff;font-size:15px;font-weight:700;cursor:pointer;margin-top:12px}
|
||
.btn-add-full:active{background:#3f3d7a}
|
||
.btn-add-full:disabled{opacity:.5}
|
||
/* Field-scan nested overlay */
|
||
.fs-ov{display:none;position:fixed;inset:0;background:rgba(15,23,42,.92);z-index:200;flex-direction:column;align-items:center;justify-content:center;padding:20px}
|
||
.fs-ov.open{display:flex}
|
||
.fs-box{background:#fff;border-radius:16px;width:100%;max-width:480px;max-height:90vh;overflow-y:auto;padding:16px}
|
||
.fs-box h3{font-size:15px;margin-bottom:10px;color:#0f172a;display:flex;align-items:center;gap:8px}
|
||
.fs-box .fs-close{margin-left:auto;background:none;border:none;font-size:22px;color:#64748b;cursor:pointer}
|
||
.fs-cam{width:100%;aspect-ratio:4/3;background:#000;border-radius:12px;overflow:hidden;margin-bottom:10px}
|
||
.fs-cam video{width:100%;height:100%;object-fit:cover}
|
||
.fs-btn{display:flex;gap:8px;margin-top:10px}
|
||
.fs-btn button{flex:1;padding:12px;border:none;border-radius:10px;font-weight:700;font-size:14px;cursor:pointer}
|
||
.fs-capture{background:#5c59a8;color:#fff}.fs-capture:active{background:#3f3d7a}
|
||
.fs-cancel{background:#f1f5f9;color:#64748b}
|
||
.conf-bar{height:4px;background:#f1f5f9;border-radius:2px;margin-top:6px;overflow:hidden}
|
||
.conf-bar .f{height:100%;transition:width .3s}
|
||
.fs-val{font-size:22px;font-weight:700;color:#0f172a;margin:10px 0 4px;text-align:center;font-family:ui-monospace,monospace}
|
||
.hist-filter{display:flex;gap:6px;margin-bottom:10px;overflow-x:auto;padding-bottom:4px}
|
||
.hist-filter button{padding:6px 12px;border:1.5px solid #e2e8f0;background:#fff;color:#64748b;border-radius:16px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
|
||
.hist-filter button.on{border-color:#5c59a8;background:#5c59a8;color:#fff}
|
||
.placeholder{background:#fff;border-radius:12px;padding:40px 24px;text-align:center;color:#94a3b8;margin-top:14px}
|
||
.placeholder .em{font-size:48px;margin-bottom:10px;opacity:.5}
|
||
.placeholder h2{font-size:16px;color:#475569;margin-bottom:6px}
|
||
.placeholder p{font-size:13px;line-height:1.5}
|
||
</style></head><body>
|
||
|
||
<!-- ═══ HOME VIEW ═══ -->
|
||
<div class="view active" id="view-home">
|
||
<div class="hdr">
|
||
<div class="hdr-d">${esc(todayFr())}</div>
|
||
<div class="hdr-n"><span class="ic">👷</span>${esc(techName)}</div>
|
||
<div class="sts">
|
||
<div class="st"><b id="stActive">${activeCount}</b><small>À FAIRE</small></div>
|
||
<div class="st"><b>${inProgress.length}</b><small>EN COURS</small></div>
|
||
<div class="st"><b>${history.length}</b><small>TERMINÉS</small></div>
|
||
</div>
|
||
</div>
|
||
<div class="wrap">
|
||
${activeCount === 0 && inProgress.length === 0 ? `
|
||
<div class="empty">
|
||
<div class="em">🎉</div>
|
||
<p>Aucune tâche active.<br/>Profitez de la pause !</p>
|
||
</div>` : ''}
|
||
${sec('En cours', inProgress)}
|
||
${sec('En retard', overdue, 'danger')}
|
||
${sec("Aujourd'hui", todayJobs)}
|
||
${sec('Sans date', nodate)}
|
||
${sec('À venir', upcoming)}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ HISTORIQUE VIEW ═══ -->
|
||
<div class="view" id="view-hist">
|
||
<div class="hdr">
|
||
<div class="hdr-d">Historique</div>
|
||
<div class="hdr-n"><span class="ic">📋</span>Tâches passées</div>
|
||
</div>
|
||
<div class="wrap">
|
||
<div class="sbar"><input class="sinp" id="histSearch" placeholder="🔍 Rechercher (client, lieu, sujet…)"></div>
|
||
<div class="hist-filter" id="histFilter">
|
||
<button class="on" data-f="all">Tous (${history.length + overdue.length})</button>
|
||
<button data-f="done">Terminés (${history.filter(j=>j.status==='Completed').length})</button>
|
||
<button data-f="overdue">Manqués (${overdue.length})</button>
|
||
<button data-f="cancelled">Annulés (${history.filter(j=>j.status==='Cancelled').length})</button>
|
||
</div>
|
||
<div id="histList">
|
||
${[...overdue, ...history].map(j => jobCard(j, today)).join('') || '<div class="empty"><div class="em">📭</div><p>Aucun historique.</p></div>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ CALENDRIER VIEW (placeholder) ═══ -->
|
||
<div class="view" id="view-cal">
|
||
<div class="hdr">
|
||
<div class="hdr-d">Calendrier</div>
|
||
<div class="hdr-n"><span class="ic">📆</span>Vue mensuelle</div>
|
||
</div>
|
||
<div class="wrap">
|
||
<div class="placeholder">
|
||
<div class="em">🚧</div>
|
||
<h2>Bientôt disponible</h2>
|
||
<p>La vue calendrier avec navigation mois/semaine arrive à la phase 4.<br/>Pour l'instant, utilisez <b>Aujourd'hui</b> ou <b>Historique</b>.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ PROFIL VIEW ═══ -->
|
||
<div class="view" id="view-profile">
|
||
<div class="hdr">
|
||
<div class="hdr-d">Profil</div>
|
||
<div class="hdr-n"><span class="ic">👤</span>${esc(techName)}</div>
|
||
</div>
|
||
<div class="wrap">
|
||
<div class="panel">
|
||
<h3>Informations</h3>
|
||
<div class="cust-line"><span class="ico">🪪</span><span>${esc(tech.name)}</span></div>
|
||
${tech.phone ? `<div class="cust-line"><span class="ico">📞</span><span>${esc(tech.phone)}</span><a href="tel:${esc(tech.phone)}">Appeler</a></div>` : ''}
|
||
${tech.email ? `<div class="cust-line"><span class="ico">✉️</span><span>${esc(tech.email)}</span></div>` : ''}
|
||
${tech.assigned_group ? `<div class="cust-line"><span class="ico">👥</span><span>${esc(tech.assigned_group)}</span></div>` : ''}
|
||
</div>
|
||
<div class="panel">
|
||
<h3>Support</h3>
|
||
<div class="cust-line"><span class="ico">📞</span><span>Ligne support</span><a href="tel:4382313838">438-231-3838</a></div>
|
||
</div>
|
||
<div class="panel" style="text-align:center;padding:20px">
|
||
<button class="savebtn" style="background:#f1f5f9;color:#64748b" onclick="location.reload()">↻ Actualiser les données</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ JOB DETAIL VIEW (filled by JS) ═══ -->
|
||
<div class="view" id="view-detail"></div>
|
||
|
||
<!-- ═══ Equipment overlay ═══ -->
|
||
<div class="ov" id="eqOv">
|
||
<div class="ov-hdr">
|
||
<button class="ov-x" onclick="closeEquip()">✕</button>
|
||
<h2 id="eqTitle">Équipement</h2>
|
||
</div>
|
||
<div class="ov-body">
|
||
<div class="cam-wrap"><video id="vid" autoplay playsinline muted></video><div class="cam-overlay"></div></div>
|
||
<div class="inp-row">
|
||
<input class="inp" id="codeInp" placeholder="SN, MAC ou code-barre…" autocomplete="off" autocapitalize="off">
|
||
<button class="btn-sm" onclick="doScan()">Chercher</button>
|
||
</div>
|
||
<div id="scanRes"></div>
|
||
<div class="ov-sec">Installé sur cette tâche</div>
|
||
<div id="eqList"><div class="sr sm">Chargement…</div></div>
|
||
<div class="ov-sec">Catalogue</div>
|
||
<div class="cat-grid" id="catGrid"><div class="sr sm">Chargement…</div></div>
|
||
<div class="af" id="addForm" style="display:none">
|
||
<div class="af-row">
|
||
<b id="afTitle">Nouvel équipement</b>
|
||
<button class="ov-x" onclick="hideForm()">✕</button>
|
||
</div>
|
||
<label>Type</label>
|
||
<div class="fwrap"><select id="afType"><option>ONT</option><option>Routeur</option><option>Modem</option><option>Décodeur TV</option><option>VoIP</option><option>Switch</option><option>AP</option><option>Amplificateur</option><option>Splitter</option><option>Autre</option></select></div>
|
||
<label>Marque</label>
|
||
<div class="fwrap"><input id="afBrand" placeholder="Ex: TP-Link, Huawei, ZTE…"></div>
|
||
<label>Modèle</label>
|
||
<div class="fwrap"><input id="afModel" placeholder="Ex: HG8245H"><button class="fscan" onclick="scanInto('model','Modèle','afModel')">📷</button></div>
|
||
<label>Numéro de série</label>
|
||
<div class="fwrap"><input id="afSn" placeholder="SN / Serial"><button class="fscan" onclick="scanInto('serial_number','Numéro de série','afSn')">📷</button></div>
|
||
<label>Adresse MAC</label>
|
||
<div class="fwrap"><input id="afMac" placeholder="AA:BB:CC:DD:EE:FF"><button class="fscan" onclick="scanInto('mac_address','Adresse MAC','afMac')">📷</button></div>
|
||
<label>GPON SN</label>
|
||
<div class="fwrap"><input id="afGpon" placeholder="HWTC12345678"><button class="fscan" onclick="scanInto('gpon_sn','GPON SN','afGpon')">📷</button></div>
|
||
<label>Wi-Fi SSID</label>
|
||
<div class="fwrap"><input id="afSsid" placeholder="Nom du réseau Wi-Fi"><button class="fscan" onclick="scanInto('wifi_ssid','SSID Wi-Fi','afSsid')">📷</button></div>
|
||
<label>Wi-Fi Password</label>
|
||
<div class="fwrap"><input id="afPwd" placeholder="Clé WPA"><button class="fscan" onclick="scanInto('wifi_password','Mot de passe Wi-Fi','afPwd')">📷</button></div>
|
||
<label>Notes</label>
|
||
<div class="fwrap"><input id="afNotes" placeholder="Port, emplacement…"></div>
|
||
<label>Action</label>
|
||
<div class="fwrap"><select id="afAction"><option value="install">Installer</option><option value="replace">Remplacer</option></select></div>
|
||
<button class="btn-add-full" id="afSubmit" onclick="submitEquip()">Ajouter l'équipement</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ Field-scan nested overlay ═══ -->
|
||
<div class="fs-ov" id="fsOv">
|
||
<div class="fs-box">
|
||
<h3><span id="fsIcon">📷</span><span id="fsLabel">Scanner</span><button class="fs-close" onclick="closeFieldScan()">✕</button></h3>
|
||
<div class="fs-cam"><video id="fsVid" autoplay playsinline muted></video></div>
|
||
<canvas id="fsCnv" style="display:none"></canvas>
|
||
<div id="fsResult"></div>
|
||
<div class="fs-btn">
|
||
<button class="fs-cancel" onclick="closeFieldScan()">Annuler</button>
|
||
<button class="fs-capture" onclick="captureFieldScan()">📸 Capturer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<!-- ═══ Bottom tab bar ═══ -->
|
||
<div class="tbar">
|
||
<button class="tab on" data-v="home" onclick="go('#home')"><span class="ic">📅</span>Aujourd'hui</button>
|
||
<button class="tab" data-v="cal" onclick="go('#cal')"><span class="ic">📆</span>Calendrier</button>
|
||
<button class="tab" data-v="hist" onclick="go('#hist')"><span class="ic">📋</span>Historique</button>
|
||
<button class="tab" data-v="profile" onclick="go('#profile')"><span class="ic">👤</span>Profil</button>
|
||
</div>
|
||
|
||
<script>
|
||
var T=${JSON.stringify(token)};
|
||
var H=${JSON.stringify(hub)};
|
||
var TODAY=${JSON.stringify(today)};
|
||
var JOBS=${jobsJson};
|
||
var CJ='',CC='',CL='',CMODEL='',CTYPE='',CBRAND='';
|
||
var stream=null,bdTimer=null,fsStream=null,fsField='',fsFieldLabel='',fsCtx={},fsCallback=null;
|
||
|
||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||
function $(id){return document.getElementById(id)}
|
||
function esc(s){return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
||
function toast(m,ok){var t=$('toast');t.textContent=m;t.style.background=ok?'#22c55e':'#ef4444';t.classList.add('on');setTimeout(function(){t.classList.remove('on')},2500)}
|
||
function fmtTime(t){if(!t)return '';var p=String(t).split(':');return p[0]+'h'+(p[1]||'00')}
|
||
function dlbl(d){if(!d)return 'Sans date';if(d===TODAY)return "Aujourd'hui";try{var diff=Math.round((new Date(d+'T00:00:00')-new Date(TODAY+'T00:00:00'))/86400000);if(diff===-1)return 'Hier';if(diff===1)return 'Demain';return new Date(d+'T00:00:00').toLocaleDateString('fr-CA',{weekday:'short',day:'numeric',month:'short'})}catch(e){return d}}
|
||
function go(h){location.hash=h}
|
||
|
||
// ── Hash routing ────────────────────────────────────────────────────────
|
||
function route(){
|
||
var h=location.hash||'#home';
|
||
// Close overlays on nav (but not if we're opening a detail)
|
||
var jm=h.match(/^#job\\/(.+)$/);
|
||
if(!jm){closeEquip();closeFieldScan();}
|
||
// Job detail
|
||
if(jm){openDetail(decodeURIComponent(jm[1]));return}
|
||
// Main tab
|
||
var v=h.slice(1);if(['home','cal','hist','profile'].indexOf(v)<0)v='home';
|
||
var views=document.querySelectorAll('.view');for(var i=0;i<views.length;i++)views[i].classList.remove('active');
|
||
$('view-'+v).classList.add('active');
|
||
var tabs=document.querySelectorAll('.tab');for(var k=0;k<tabs.length;k++){tabs[k].classList.toggle('on',tabs[k].dataset.v===v)}
|
||
window.scrollTo(0,0);
|
||
}
|
||
window.addEventListener('hashchange',route);route();
|
||
|
||
// ── Status update ───────────────────────────────────────────────────────
|
||
function doSt(j,s,btn){
|
||
if(btn){btn.classList.add('dis');btn.textContent='…'}
|
||
fetch(H+'/t/'+T+'/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({job:j,status:s})})
|
||
.then(function(r){return r.json()}).then(function(d){
|
||
if(d.ok||d.status==='In Progress'||d.status==='Completed'){
|
||
toast(s==='Completed'?'Terminé ✓':'En route ▶',true);
|
||
if(JOBS[j])JOBS[j].status=s;
|
||
setTimeout(function(){location.reload()},700);
|
||
}else{toast('Erreur: '+(d.error||''),false);if(btn)btn.classList.remove('dis')}
|
||
}).catch(function(){toast('Erreur réseau',false);if(btn)btn.classList.remove('dis')});
|
||
}
|
||
|
||
// ── Historique search & filter ──────────────────────────────────────────
|
||
var histAll=document.querySelectorAll('#histList .card');
|
||
var histSearch=$('histSearch');
|
||
if(histSearch)histSearch.addEventListener('input',function(){applyHistFilter()});
|
||
var hf=document.querySelectorAll('#histFilter button');
|
||
for(var i=0;i<hf.length;i++){hf[i].addEventListener('click',function(e){
|
||
for(var j=0;j<hf.length;j++)hf[j].classList.remove('on');e.target.classList.add('on');applyHistFilter();
|
||
})}
|
||
function applyHistFilter(){
|
||
var q=(histSearch?histSearch.value:'').toLowerCase().trim();
|
||
var fbtn=document.querySelector('#histFilter button.on');
|
||
var f=fbtn?fbtn.dataset.f:'all';
|
||
var cards=document.querySelectorAll('#histList .card');
|
||
for(var i=0;i<cards.length;i++){
|
||
var c=cards[i];var txt=c.textContent.toLowerCase();
|
||
var jid=c.querySelector('.jid').textContent;
|
||
var j=JOBS[jid];if(!j){c.style.display='none';continue}
|
||
var okQ=!q||txt.indexOf(q)>=0;
|
||
var okF=true;
|
||
if(f==='done')okF=j.status==='Completed';
|
||
else if(f==='cancelled')okF=j.status==='Cancelled';
|
||
else if(f==='overdue')okF=j.status!=='Completed'&&j.status!=='Cancelled'&&j.scheduled_date&&j.scheduled_date<TODAY;
|
||
c.style.display=(okQ&&okF)?'block':'none';
|
||
}
|
||
}
|
||
|
||
// ── Job detail view ─────────────────────────────────────────────────────
|
||
function openDetail(name){
|
||
var j=JOBS[name];
|
||
if(!j){toast('Tâche introuvable',false);go('#home');return}
|
||
CJ=j.name;CC=j.customer||'';CL=j.service_location||'';
|
||
var views=document.querySelectorAll('.view');for(var i=0;i<views.length;i++)views[i].classList.remove('active');
|
||
$('view-detail').classList.add('active');
|
||
$('view-detail').innerHTML=renderDetail(j);
|
||
// Async refresh secondary data
|
||
loadPhotos();loadEquipList();
|
||
window.scrollTo(0,0);
|
||
}
|
||
|
||
function renderDetail(j){
|
||
var done=j.status==='Completed'||j.status==='Cancelled';
|
||
var canStart=['Scheduled','assigned','open'].indexOf(j.status)>=0;
|
||
var canFinish=j.status==='In Progress'||j.status==='in_progress';
|
||
var gps=j.service_location_name?'https://www.google.com/maps/dir/?api=1&destination='+encodeURIComponent(j.service_location_name):'';
|
||
var sMeta={Scheduled:['Planifié','#818cf8'],assigned:['Assigné','#818cf8'],open:['Ouvert','#818cf8'],'In Progress':['En cours','#f59e0b'],in_progress:['En cours','#f59e0b'],Completed:['Terminé','#22c55e'],Cancelled:['Annulé','#94a3b8']};
|
||
var sm=sMeta[j.status]||[j.status||'—','#94a3b8'];
|
||
var urgent=j.priority==='urgent'||j.priority==='high';
|
||
var addr=j.address||j.service_location_name||'';
|
||
return ''
|
||
+'<div class="dt-hdr">'
|
||
+ '<div class="nav"><button onclick="go(\\'#home\\')">‹</button><div class="jn">'+esc(j.name)+'</div></div>'
|
||
+ '<h1>'+esc(j.subject||'Sans titre')+'</h1>'
|
||
+ '<div class="meta">'
|
||
+ '<span style="background:'+sm[1]+'">'+sm[0]+'</span>'
|
||
+ '<span>📅 '+dlbl(j.scheduled_date)+(j.start_time?' · '+fmtTime(j.start_time):'')+'</span>'
|
||
+ (j.duration_h?'<span>⏱ '+j.duration_h+'h</span>':'')
|
||
+ (urgent?'<span style="background:#ef4444">🔥 Urgent</span>':'')
|
||
+ (j.job_type?'<span>'+esc(j.job_type)+'</span>':'')
|
||
+ '</div>'
|
||
+'</div>'
|
||
// Customer + location
|
||
+(j.customer_name||addr?(''
|
||
+'<div class="panel">'
|
||
+ '<h3>Client & lieu</h3>'
|
||
+ (j.customer_name?'<div class="cust-line"><span class="ico">👤</span><span>'+esc(j.customer_name)+'</span></div>':'')
|
||
+ (addr?'<div class="cust-line"><span class="ico">📍</span><span>'+esc(addr)+'</span>'+(gps?'<a href="'+gps+'" target="_blank">GPS</a>':'')+'</div>':'')
|
||
+'</div>'):'')
|
||
// Description
|
||
+(j.description?(''
|
||
+'<div class="panel">'
|
||
+ '<h3>Description</h3>'
|
||
+ '<div class="desc">'+esc(j.description)+'</div>'
|
||
+'</div>'):'')
|
||
// Notes (editable)
|
||
+'<div class="panel">'
|
||
+ '<h3>Notes du technicien <span class="rt">Éditable</span></h3>'
|
||
+ '<textarea class="notes-area" id="jobNotes" placeholder="Observations, codes, contacts…">'+esc(j.notes||'')+'</textarea>'
|
||
+ '<button class="savebtn" id="saveNotesBtn" onclick="saveNotes()">💾 Enregistrer les notes</button>'
|
||
+'</div>'
|
||
// Photos
|
||
+'<div class="panel">'
|
||
+ '<h3>Photos <span class="rt"><input type="file" id="photoInp" accept="image/*" capture="environment" style="display:none" onchange="onPhotoPick(this)"></span></h3>'
|
||
+ '<div class="photo-grid" id="photoGrid">'
|
||
+ '<div class="photo-add" onclick="$(\\'photoInp\\').click()">+</div>'
|
||
+ '</div>'
|
||
+'</div>'
|
||
// Equipment
|
||
+'<div class="panel">'
|
||
+ '<h3>Équipement installé</h3>'
|
||
+ '<div id="dtEquipList"><div class="sr sm">Chargement…</div></div>'
|
||
+ '<button class="add-eq-btn" onclick="openEquip(CJ,CC,CL)">📷 Ajouter / scanner un équipement</button>'
|
||
+'</div>'
|
||
// Action bar
|
||
+(!done?(''
|
||
+'<div class="action-bar">'
|
||
+ (gps?'<a href="'+gps+'" target="_blank" class="btn btn-nav">📍 Naviguer</a>':'')
|
||
+ (canStart?'<button class="btn btn-start" onclick="doSt(\\''+j.name+'\\',\\'In Progress\\',this)">▶ Démarrer</button>':'')
|
||
+ (canFinish?'<button class="btn btn-finish" onclick="doSt(\\''+j.name+'\\',\\'Completed\\',this)">✓ Terminer</button>':'')
|
||
+'</div>'):'')
|
||
}
|
||
|
||
// ── Notes ───────────────────────────────────────────────────────────────
|
||
function saveNotes(){
|
||
var el=$('jobNotes');if(!el)return;
|
||
var b=$('saveNotesBtn');b.disabled=true;b.textContent='Enregistrement…';
|
||
fetch(H+'/t/'+T+'/note',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({job:CJ,notes:el.value})})
|
||
.then(function(r){return r.json()}).then(function(d){
|
||
if(d.ok){toast('Notes enregistrées ✓',true);if(JOBS[CJ])JOBS[CJ].notes=el.value}
|
||
else toast('Erreur: '+(d.error||''),false);
|
||
b.disabled=false;b.textContent='💾 Enregistrer les notes';
|
||
}).catch(function(){toast('Erreur réseau',false);b.disabled=false;b.textContent='💾 Enregistrer les notes'});
|
||
}
|
||
|
||
// ── Photos ──────────────────────────────────────────────────────────────
|
||
function loadPhotos(){
|
||
var el=$('photoGrid');if(!el)return;
|
||
fetch(H+'/t/'+T+'/photos?job='+encodeURIComponent(CJ)).then(function(r){return r.json()}).then(function(d){
|
||
if(!el)return;
|
||
var add='<div class="photo-add" onclick="$(\\'photoInp\\').click()">+</div>';
|
||
if(!d.ok){el.innerHTML=add;return}
|
||
var items=d.items||[];
|
||
el.innerHTML=add+items.map(function(ph){
|
||
var src=H+'/t/'+T+'/photo-serve?p='+encodeURIComponent(ph.file_url);
|
||
return '<div class="ph"><a href="'+src+'" target="_blank"><img src="'+src+'" loading="lazy"/></a></div>';
|
||
}).join('');
|
||
}).catch(function(){});
|
||
}
|
||
|
||
function onPhotoPick(input){
|
||
var f=input.files[0];if(!f)return;
|
||
input.value='';
|
||
var reader=new FileReader();
|
||
reader.onload=function(e){
|
||
toast('Envoi…',true);
|
||
fetch(H+'/t/'+T+'/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({job:CJ,image:e.target.result,file_name:f.name})})
|
||
.then(function(r){return r.json()}).then(function(d){
|
||
if(d.ok){toast('Photo ajoutée ✓',true);loadPhotos()}
|
||
else toast('Erreur: '+(d.error||''),false);
|
||
}).catch(function(){toast('Erreur réseau',false)});
|
||
};
|
||
reader.readAsDataURL(f);
|
||
}
|
||
|
||
// ── Equipment overlay ──────────────────────────────────────────────────
|
||
function openEquip(job,cust,loc){
|
||
CJ=job||CJ;CC=cust||CC;CL=loc||CL;
|
||
$('eqTitle').textContent='📷 '+CJ;
|
||
$('eqOv').classList.add('open');
|
||
$('codeInp').value='';$('scanRes').innerHTML='';
|
||
hideForm();startCam();loadEquipList();loadCatalog();
|
||
}
|
||
function closeEquip(){$('eqOv').classList.remove('open');stopCam()}
|
||
|
||
function startCam(){
|
||
var v=$('vid');
|
||
if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){v.style.display='none';return}
|
||
navigator.mediaDevices.getUserMedia({video:{facingMode:'environment',width:{ideal:1280},height:{ideal:720}}})
|
||
.then(function(s){stream=s;v.srcObject=s;v.style.display='block';
|
||
if('BarcodeDetector' in window){
|
||
try{
|
||
var bd=new BarcodeDetector({formats:['qr_code','code_128','ean_13','ean_8','code_39']});
|
||
bdTimer=setInterval(function(){bd.detect(v).then(function(c){if(c.length){$('codeInp').value=c[0].rawValue;doScan()}}).catch(function(){})},500)
|
||
}catch(e){}
|
||
}
|
||
}).catch(function(){v.style.display='none'})
|
||
}
|
||
function stopCam(){if(bdTimer){clearInterval(bdTimer);bdTimer=null}if(stream){stream.getTracks().forEach(function(t){t.stop()});stream=null}var v=$('vid');if(v)v.srcObject=null}
|
||
|
||
function doScan(){
|
||
var code=$('codeInp').value.trim();if(!code)return;
|
||
var r=$('scanRes');r.innerHTML='<div class="sr sm">Recherche…</div>';
|
||
fetch(H+'/t/'+T+'/scan',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:code})})
|
||
.then(function(x){return x.json()}).then(function(d){
|
||
if(!d.ok){r.innerHTML='<div class="sr" style="color:#ef4444">'+(d.error||'erreur')+'</div>';return}
|
||
if(!d.results.length){
|
||
r.innerHTML='<div class="sr">Aucun résultat pour <b>'+esc(code)+'</b>. <a href="#" onclick="showForm("'+esc(code)+'");return false" style="color:#5c59a8;font-weight:600;margin-left:6px">Créer nouveau →</a></div>';
|
||
// Also pre-fill the form serial field
|
||
$('afSn').value=code;
|
||
return;
|
||
}
|
||
r.innerHTML=d.results.map(function(it){
|
||
var sn=it.serial_number||it.serial_no||it.name;
|
||
var nm=it.item_name||it.item_code||it.equipment_type||'';
|
||
var st=it.status||'';
|
||
return '<div class="sr"><b>'+esc(sn)+'</b> '+esc(nm)+(st?' <span class="bdg" style="background:#818cf820;color:#818cf8">'+esc(st)+'</span>':'')+'</div>';
|
||
}).join('');
|
||
}).catch(function(){r.innerHTML='<div class="sr" style="color:#ef4444">Erreur réseau</div>'});
|
||
}
|
||
|
||
function loadEquipList(){
|
||
// Render in BOTH the overlay list and the detail panel (if visible)
|
||
var urls=[['eqList',true],['dtEquipList',false]];
|
||
fetch(H+'/t/'+T+'/equip-list?job='+encodeURIComponent(CJ)).then(function(r){return r.json()}).then(function(d){
|
||
for(var i=0;i<urls.length;i++){
|
||
var el=$(urls[i][0]);if(!el)continue;
|
||
if(!d.ok||!d.items.length){el.innerHTML='<div class="sr sm">Aucun équipement installé</div>';continue}
|
||
el.innerHTML=d.items.map(function(it){
|
||
var icon=it.action==='remove'?'🔴':'🟢';
|
||
var rm=(it.action!=='remove'&&urls[i][1])?'<button class="eq-rm" onclick="removeEquip("'+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>';
|
||
fetch(H+'/t/'+T+'/catalog').then(function(r){return r.json()}).then(function(d){
|
||
if(!d.ok||!d.items.length){g.innerHTML='<div class="sr sm">Catalogue vide</div>';return}
|
||
g.innerHTML=d.items.map(function(it){
|
||
return '<div class="cat-card" onclick="showFormCat("'+esc(it.item_name||it.name)+'","'+esc(it.name)+'","'+esc(it.item_group||'')+'")"><div class="cn">'+esc(it.item_name||it.name)+'</div><div class="cd">'+esc(it.item_group||'')+'</div>'+(it.standard_rate?'<div class="cp">'+Number(it.standard_rate).toFixed(2)+'$</div>':'')+'</div>';
|
||
}).join('');
|
||
}).catch(function(){g.innerHTML='<div class="sr sm">Erreur réseau</div>'});
|
||
}
|
||
|
||
function showForm(sn){
|
||
var f=$('addForm');f.style.display='block';
|
||
$('afSn').value=sn||'';$('afBrand').value='';$('afModel').value='';
|
||
$('afMac').value='';$('afGpon').value='';$('afSsid').value='';$('afPwd').value='';$('afNotes').value='';
|
||
$('afTitle').textContent='Nouvel équipement';
|
||
f.scrollIntoView({behavior:'smooth',block:'start'});
|
||
}
|
||
function showFormCat(name,code,group){
|
||
showForm($('codeInp').value||'');
|
||
$('afTitle').textContent=name;
|
||
CMODEL=code;CTYPE=group||'';
|
||
}
|
||
function hideForm(){$('addForm').style.display='none'}
|
||
|
||
function submitEquip(){
|
||
var b=$('afSubmit');b.disabled=true;b.textContent='Enregistrement…';
|
||
var data={
|
||
job:CJ,customer:CC,location:CL,
|
||
barcode:$('afSn').value.trim(),
|
||
equipment_type:$('afType').value,
|
||
brand:$('afBrand').value.trim(),
|
||
model:$('afModel').value.trim(),
|
||
mac_address:$('afMac').value.trim().replace(/[:\\-\\.\\s]/g,'').toUpperCase(),
|
||
gpon_sn:$('afGpon').value.trim(),
|
||
wifi_ssid:$('afSsid').value.trim(),
|
||
wifi_password:$('afPwd').value.trim(),
|
||
notes:$('afNotes').value.trim(),
|
||
action:$('afAction').value,
|
||
};
|
||
fetch(H+'/t/'+T+'/equip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})
|
||
.then(function(r){return r.json()}).then(function(d){
|
||
if(d.ok){toast('Équipement ajouté ✓',true);hideForm();loadEquipList()}
|
||
else toast('Erreur: '+(d.error||''),false);
|
||
b.disabled=false;b.textContent="Ajouter l'équipement";
|
||
}).catch(function(){toast('Erreur réseau',false);b.disabled=false;b.textContent="Ajouter l'équipement"});
|
||
}
|
||
|
||
function removeEquip(name,sn,type){
|
||
if(!confirm('Retirer cet équipement ?'))return;
|
||
fetch(H+'/t/'+T+'/equip-remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({equipment:name,serial:sn,equipment_type:type,job:CJ})})
|
||
.then(function(r){return r.json()}).then(function(d){if(d.ok){toast('Retiré ✓',true);loadEquipList()}else{toast('Erreur',false)}}).catch(function(){toast('Erreur réseau',false)});
|
||
}
|
||
|
||
$('codeInp').addEventListener('keydown',function(e){if(e.key==='Enter')doScan()});
|
||
|
||
// ── Field-targeted scan (Gemini) ────────────────────────────────────────
|
||
function scanInto(field,label,targetId){
|
||
openFieldScan(field,label,function(value){
|
||
var t=$(targetId);if(t){t.value=value;t.dispatchEvent(new Event('input'))}
|
||
toast(label+': '+value,true);
|
||
},{
|
||
equipment_type:$('afType')?$('afType').value:'',
|
||
brand:$('afBrand')?$('afBrand').value.trim():'',
|
||
model:$('afModel')?$('afModel').value.trim():'',
|
||
});
|
||
}
|
||
|
||
function openFieldScan(field,label,callback,ctx){
|
||
fsField=field;fsFieldLabel=label;fsCallback=callback;fsCtx=ctx||{};
|
||
$('fsLabel').textContent='Scanner : '+label;
|
||
$('fsResult').innerHTML='<div class="sr sm" style="text-align:center">Cadrez l\\'étiquette puis appuyez sur <b>Capturer</b></div>';
|
||
$('fsOv').classList.add('open');
|
||
var v=$('fsVid');
|
||
if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){v.style.display='none';$('fsResult').innerHTML='<div class="sr" style="color:#ef4444">Caméra indisponible</div>';return}
|
||
navigator.mediaDevices.getUserMedia({video:{facingMode:'environment',width:{ideal:1280},height:{ideal:720}}}).then(function(s){
|
||
fsStream=s;v.srcObject=s;v.style.display='block';
|
||
}).catch(function(e){
|
||
$('fsResult').innerHTML='<div class="sr" style="color:#ef4444">Caméra refusée: '+(e.message||'')+'</div>';
|
||
});
|
||
}
|
||
function closeFieldScan(){
|
||
$('fsOv').classList.remove('open');
|
||
if(fsStream){fsStream.getTracks().forEach(function(t){t.stop()});fsStream=null}
|
||
var v=$('fsVid');if(v)v.srcObject=null;
|
||
fsCallback=null;
|
||
}
|
||
function captureFieldScan(){
|
||
var v=$('fsVid'),c=$('fsCnv');
|
||
if(!v.videoWidth){toast('Caméra non prête',false);return}
|
||
c.width=v.videoWidth;c.height=v.videoHeight;
|
||
var ctx=c.getContext('2d');ctx.drawImage(v,0,0,c.width,c.height);
|
||
var b64=c.toDataURL('image/jpeg',0.85);
|
||
$('fsResult').innerHTML='<div class="sr sm" style="text-align:center">🤖 Analyse par Gemini Vision…</div>';
|
||
var payload={image:b64,field:fsField};
|
||
if(fsCtx.equipment_type)payload.equipment_type=fsCtx.equipment_type;
|
||
if(fsCtx.brand)payload.brand=fsCtx.brand;
|
||
if(fsCtx.model)payload.model=fsCtx.model;
|
||
fetch(H+'/t/'+T+'/field-scan',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
|
||
.then(function(r){return r.json()}).then(function(d){
|
||
if(!d.ok||!d.value){$('fsResult').innerHTML='<div class="sr" style="color:#ef4444;text-align:center">❌ Non détecté — rapprochez-vous et réessayez.</div>';return}
|
||
var pct=Math.round((d.confidence||0)*100);
|
||
var color=pct>=70?'#22c55e':pct>=40?'#f59e0b':'#ef4444';
|
||
$('fsResult').innerHTML=''
|
||
+'<div class="sr">'
|
||
+ '<div class="fs-val">'+esc(d.value)+'</div>'
|
||
+ '<div class="sm" style="text-align:center">Confiance : <b style="color:'+color+'">'+pct+'%</b></div>'
|
||
+ '<div class="conf-bar"><div class="f" style="width:'+pct+'%;background:'+color+'"></div></div>'
|
||
+ '<div style="display:flex;gap:8px;margin-top:12px">'
|
||
+ '<button class="btn-sm" style="background:'+color+';flex:1" onclick="confirmFieldScan("'+esc(d.value)+'")">✓ Utiliser</button>'
|
||
+ '<button class="btn-sm" style="background:#94a3b8" onclick="captureFieldScan()">↻ Réessayer</button>'
|
||
+ '</div>'
|
||
+'</div>';
|
||
}).catch(function(e){$('fsResult').innerHTML='<div class="sr" style="color:#ef4444">Erreur réseau : '+(e.message||'')+'</div>'});
|
||
}
|
||
function confirmFieldScan(value){
|
||
if(fsCallback)fsCallback(value);
|
||
closeFieldScan();
|
||
}
|
||
|
||
</script></body></html>`
|
||
}
|
||
|
||
function pageExpired () {
|
||
return `<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#3f3d7a"><title>Lien expiré</title>
|
||
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,sans-serif;background:#eef1f5;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}.b{background:#fff;border-radius:16px;padding:32px 24px;text-align:center;max-width:340px;box-shadow:0 2px 12px rgba(0,0,0,.08)}.i{font-size:48px;margin-bottom:12px}h1{font-size:18px;margin-bottom:8px}p{font-size:14px;color:#64748b;line-height:1.5}</style>
|
||
</head><body><div class="b"><div class="i">🔗</div><h1>Lien expiré</h1><p>Ce lien n'est plus valide. Votre superviseur vous enverra un nouveau lien par SMS.</p></div></body></html>`
|
||
}
|
||
|
||
function pageError () {
|
||
return `<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Erreur</title>
|
||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#eef1f5;padding:24px}.b{background:#fff;border-radius:16px;padding:32px;text-align:center;max-width:340px}h1{font-size:18px;margin-bottom:8px}p{font-size:13px;color:#64748b}</style>
|
||
</head><body><div class="b"><h1>Erreur temporaire</h1><p>Réessayez dans quelques instants.</p></div></body></html>`
|
||
}
|
||
|
||
module.exports = { route }
|