diff --git a/services/targo-hub/lib/tech-mobile.js b/services/targo-hub/lib/tech-mobile.js index f6d61a3..b92d5eb 100644 --- a/services/targo-hub/lib/tech-mobile.js +++ b/services/targo-hub/lib/tech-mobile.js @@ -2,12 +2,18 @@ const cfg = require('./config') const { log, json, erpFetch } = require('./helpers') const { verifyJwt } = require('./magic-link') +const { extractField } = require('./vision') -// ── Lightweight server-rendered mobile page for field techs ─────────────── -// No framework, inline CSS+JS, <15KB HTML. Scanner uses native BarcodeDetector -// + fallback photo→Gemini Vision. Equipment CRUD via targo-hub proxy to ERPNext. +// ── 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 helper ────────────────────────────────────────────────────────────── +// ── Auth helpers ───────────────────────────────────────────────────────────── function authToken (path) { const m = path.match(/^\/t\/([A-Za-z0-9_\-\.]+)/) @@ -21,56 +27,108 @@ async function readBody (req) { 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 + // 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 → job status update - if (method === 'POST' && path.endsWith('/status')) return handleStatus(req, res, path) - // POST /t/{token}/scan → barcode/serial lookup - if (method === 'POST' && path.endsWith('/scan')) return handleScan(req, res, path) - // POST /t/{token}/vision → photo → Gemini AI equipment label read - if (method === 'POST' && path.endsWith('/vision')) return handleVision(req, res, path) - // POST /t/{token}/equip → create Equipment Install - if (method === 'POST' && path.endsWith('/equip')) return handleEquipInstall(req, res, path) - // POST /t/{token}/equip-remove → remove/return equipment + // 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 → equipment catalog items - if (method === 'GET' && path.endsWith('/catalog')) return handleCatalog(req, res, path) - // GET /t/{token}/equip-list → equipment installed on a job - if (method === 'GET' && path.endsWith('/equip-list')) return handleEquipList(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' }) } -// ── GET /t/{token} — Server-rendered page ──────────────────────────────────── +// ── 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 - const today = new Date().toISOString().slice(0, 10) + // 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=["name","full_name","phone"]`) - const techName = techRes.status === 200 && techRes.data?.data?.full_name ? techRes.data.data.full_name : techId + 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 } - const fields = '["name","subject","status","customer","customer_name","service_location","service_location_name","scheduled_time","scheduled_date","duration_h","priority","description","job_type"]' - const filters = encodeURIComponent(JSON.stringify([['assigned_tech', '=', techId], ['scheduled_date', '=', today]])) - const jobsRes = await erpFetch(`/api/resource/Dispatch%20Job?fields=${fields}&filters=${filters}&order_by=scheduled_time+asc&limit_page_length=50`) + // 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(techName, jobs, path.split('/')[2])) + res.end(renderPage({ tech, jobs, token: path.split('/')[2], today })) } catch (e) { - log('tech-mobile error:', e.message) + log('tech-mobile page error:', e.message) res.writeHead(500, html()) res.end(pageError()) } } -// ── POST /t/{token}/status — Update job status ────────────────────────────── +// ── Status update (reuse dispatch chain walker) ────────────────────────────── async function handleStatus (req, res, path) { const payload = authToken(path) @@ -79,50 +137,164 @@ async function handleStatus (req, res, path) { 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 { - // Single source of truth: setJobStatusWithChain PUTs status, broadcasts SSE, - // and walks the chain (unblocks `depends_on` children on Completed). 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 }) } } -// ── POST /t/{token}/scan — Barcode/serial lookup ──────────────────────────── +// ── 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 { - // Search Service Equipment 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=["name","serial_number","item_name","equipment_type","mac_address","status","customer","service_location"]&limit_page_length=5`) + 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 }) } - // Search Serial No const f = encodeURIComponent(JSON.stringify([['serial_no', 'like', `%${q}%`]])) - const r = await erpFetch(`/api/resource/Serial%20No?filters=${f}&fields=["name","serial_no","item_code","status","warehouse"]&limit_page_length=5`) + 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 }) } } -// ── POST /t/{token}/vision — Photo → Gemini AI label read ─────────────────── - 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' }) - - // Proxy to existing vision.js extractBarcodes + equipment logic try { const { extractBarcodes } = require('./vision') const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '') @@ -131,7 +303,7 @@ async function handleVision (req, res, path) { } catch (e) { return json(res, 500, { error: e.message }) } } -// ── POST /t/{token}/equip — Create Equipment Install ──────────────────────── +// ── Equipment Install / Remove / Catalog ──────────────────────────────────── async function handleEquipInstall (req, res, path) { const payload = authToken(path) @@ -139,108 +311,100 @@ async function handleEquipInstall (req, res, path) { const body = await readBody(req) if (!body) return json(res, 400, { error: 'body required' }) - const { job, barcode, equipment_type, brand, model, notes, customer, location, action } = body + 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. Create Equipment Install record + // 1. Equipment Install log const installData = { request: job || '', barcode: barcode || '', equipment_type: equipment_type || '', brand: brand || '', model: model || '', - notes: notes || '', + 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', // install | replace | remove + action: action || 'install', } const r = await erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify(installData) }) - // 2. Try to link/create Service Equipment record + // 2. Link/create Service Equipment when installing/replacing if (barcode && (action === 'install' || action === 'replace')) { try { - // Check if equipment exists 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=["name"]&limit_page_length=1`) + 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) { - // Update existing: link to customer/location await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(existing.data.data[0].name)}`, { - method: 'PUT', - body: JSON.stringify({ - status: 'Actif', - customer: customer || '', - service_location: location || '', - }), + method: 'PUT', body: JSON.stringify(seUpdate), }) } else { - // Create new await erpFetch('/api/resource/Service%20Equipment', { method: 'POST', - body: JSON.stringify({ - serial_number: barcode, - equipment_type: equipment_type || 'ONT', - brand: brand || '', - model: model || '', - status: 'Actif', - customer: customer || '', - service_location: location || '', - }), + 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 }) } } -// ── POST /t/{token}/equip-remove — Remove/return equipment ────────────────── - 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: '', - }), + body: JSON.stringify({ status: body.status || 'Retourné', customer: '', service_location: '' }), }) - // Log as Equipment Install with action=remove await erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify({ - request: body.job || '', - barcode: body.serial || '', + 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', + technician: payload.sub, action: 'remove', }), }) return json(res, 200, { ok: true }) } catch (e) { return json(res, 500, { error: e.message }) } } -// ── GET /t/{token}/catalog — Equipment catalog ────────────────────────────── - 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=["name","item_name","item_group","standard_rate","image","description"]&limit_page_length=50`) + 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 : [] - // Fallback hardcoded if API returns nothing 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' }, @@ -254,295 +418,765 @@ async function handleCatalog (req, res, path) { } catch (e) { return json(res, 500, { error: e.message }) } } -// ── GET /t/{token}/equip-list?job=X — Equipment on a job ──────────────────── - 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=["name","barcode","equipment_type","brand","model","action","notes","installation_date"]&order_by=creation+desc&limit_page_length=20`) + 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 RENDERING +// HTML // ═════════════════════════════════════════════════════════════════════════════ function html () { return { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store' } } -function esc (s) { return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } -function fmtTime (t) { if (!t) return ''; const [h, m] = t.split(':'); return `${h}h${m}` } +function esc (s) { return (s == null ? '' : String(s)).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 map = { Scheduled: ['À faire', '#818cf8'], assigned: ['Assigné', '#818cf8'], open: ['Ouvert', '#818cf8'], 'In Progress': ['En cours', '#f59e0b'], in_progress: ['En cours', '#f59e0b'], Completed: ['Terminé', '#22c55e'], Cancelled: ['Annulé', '#ef4444'] } - const [l, c] = map[s] || [s, '#94a3b8'] - return `${esc(l)}` + const m = STATUS_META[s] || { label: s || '—', color: '#94a3b8' } + return `${esc(m.label)}` } -function jobCard (j) { +function jobCard (j, today) { const urgent = j.priority === 'urgent' || j.priority === 'high' - const border = urgent ? '#ef4444' : j.status === 'Completed' ? '#22c55e' : '#5c59a8' - const canStart = ['Scheduled', 'assigned', 'open'].includes(j.status) - const canFinish = ['In Progress', 'in_progress'].includes(j.status) - const done = j.status === 'Completed' - const loc = j.service_location_name || '' - const gps = loc ? `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(loc)}` : '' - return `
-
${esc(j.name)}${badge(j.status)}${fmtTime(j.scheduled_time)}
-
${esc(j.subject || 'Sans titre')}
- ${j.customer_name ? `
${esc(j.customer_name)}
` : ''} - ${loc ? `
📍 ${esc(loc)}
` : ''} -
${j.duration_h ? `⏱ ${j.duration_h}h` : ''} ${j.job_type ? esc(j.job_type) : ''}
-
- ${gps ? `📍 Naviguer` : ''} - ${!done ? `` : ''} - ${canStart ? `` : ''} - ${canFinish ? `` : ''} -
` + 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 `
+
${esc(j.name)}${badge(j.status)}${esc(datePart)}
+
${esc(j.subject || 'Sans titre')}
+ ${j.customer_name ? `
👤 ${esc(j.customer_name)}
` : ''} + ${j.service_location_name ? `
📍 ${esc(j.service_location_name)}
` : ''} +
${j.duration_h ? `⏱ ${j.duration_h}h` : ''} ${j.job_type ? esc(j.job_type) : ''}${urgent ? ' 🔥' : ''}
+
` } -function renderPage (techName, jobs, token) { - const todo = jobs.filter(j => j.status !== 'Completed' && j.status !== 'Cancelled') - const ip = jobs.filter(j => j.status === 'In Progress' || j.status === 'in_progress') - const up = jobs.filter(j => ['Scheduled', 'assigned', 'open'].includes(j.status)) - const done = jobs.filter(j => j.status === 'Completed') +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, '--\\u003e') + + const sec = (title, list, mod = '') => list.length + ? `
${title} ${list.length}
${list.map(j => jobCard(j, today)).join('')}` + : '' return ` Mes tâches — Gigafibre -
-
${esc(todayFr())}
-
${esc(techName)}
-
-
${jobs.length}Total
-
${todo.length}À faire
-
${done.length}Faits
+ +
+
+
${esc(todayFr())}
+
👷${esc(techName)}
+
+
${activeCount}À FAIRE
+
${inProgress.length}EN COURS
+
${history.length}TERMINÉS
+
+
+
+ ${activeCount === 0 && inProgress.length === 0 ? ` +
+
🎉
+

Aucune tâche active.
Profitez de la pause !

+
` : ''} + ${sec('En cours', inProgress)} + ${sec('En retard', overdue, 'danger')} + ${sec("Aujourd'hui", todayJobs)} + ${sec('Sans date', nodate)} + ${sec('À venir', upcoming)}
-
-${jobs.length === 0 ? '
Aucune tâche aujourd\'hui 🎉
' : ''} -${ip.length ? `
En cours (${ip.length})
${ip.map(j => jobCard(j)).join('')}` : ''} -${up.length ? `
À venir (${up.length})
${up.map(j => jobCard(j)).join('')}` : ''} -${done.length ? `
Terminés (${done.length})
${done.map(j => jobCard(j)).join('')}` : ''} + +
+
+
Historique
+
📋Tâches passées
+
+
+
+
+ + + + +
+
+ ${[...overdue, ...history].map(j => jobCard(j, today)).join('') || '
📭

Aucun historique.

'} +
+
+ +
+
+
Calendrier
+
📆Vue mensuelle
+
+
+
+
🚧
+

Bientôt disponible

+

La vue calendrier avec navigation mois/semaine arrive à la phase 4.
Pour l'instant, utilisez Aujourd'hui ou Historique.

+
+
+
+ + +
+
+
Profil
+
👤${esc(techName)}
+
+
+
+

Informations

+
🪪${esc(tech.name)}
+ ${tech.phone ? `
📞${esc(tech.phone)}Appeler
` : ''} + ${tech.email ? `
✉️${esc(tech.email)}
` : ''} + ${tech.assigned_group ? `
👥${esc(tech.assigned_group)}
` : ''} +
+
+

Support

+
📞Ligne support438-231-3838
+
+
+ +
+
+
+ + +
+
-

📷 Équipement

+
+ +

Équipement

+
- -
+
- -
- - -
Équipement installé
+
Installé sur cette tâche
Chargement…
- - -
Ajouter depuis le catalogue
+
Catalogue
Chargement…
- -
+ +
+
+

📷Scanner

+
+ +
+
+ + +
+
+
+
-
- + + +
+ + + +
` } diff --git a/services/targo-hub/lib/vision.js b/services/targo-hub/lib/vision.js index ce4a36a..d08d635 100644 --- a/services/targo-hub/lib/vision.js +++ b/services/targo-hub/lib/vision.js @@ -167,4 +167,86 @@ async function handleInvoice (req, res) { } } -module.exports = { handleBarcodes, extractBarcodes, handleEquipment, handleInvoice } +// ─── Field-targeted extraction (for tech mobile form auto-fill) ───────── +// Instead of "read everything on the label", this pulls ONE specific value. +// Used when a tech has selected e.g. "Wi-Fi password" and wants Gemini to +// find only that field on the sticker. Returns {value, confidence}. + +const FIELD_CONFIG = { + serial_number: { + desc: 'the device SERIAL NUMBER (labeled S/N, SN, Serial, N/S). Usually 8-20 alphanumeric chars, frequently printed under a Code128 barcode.', + clean: v => v.replace(/\s+/g, '').toUpperCase(), + }, + mac_address: { + desc: 'the MAC ADDRESS (12 hexadecimal chars, may be separated by colons, dashes or nothing). Labeled MAC, WAN MAC, LAN MAC, Ethernet, Wi-Fi MAC.', + clean: v => v.replace(/[^0-9A-F]/gi, '').toUpperCase(), + }, + gpon_sn: { + desc: 'the GPON SN — a 4-letter manufacturer code followed by 8 hex characters (e.g. HWTC12345678, ZTEG87654321, CIGG1A2B3C4D). Labeled GPON SN, GPON-SN, ONU SN.', + clean: v => v.replace(/\s+/g, '').toUpperCase(), + }, + model: { + desc: 'the MODEL number/name (labeled M/N, Model, P/N, Product, Type). Usually short, e.g. "HG8245H", "TL-WR841N", "HS8145V".', + clean: v => v.trim(), + }, + wifi_ssid: { + desc: 'the Wi-Fi NETWORK NAME (SSID). Labeled SSID, Wi-Fi name, WLAN SSID, Nom Wi-Fi, Nom du réseau.', + clean: v => v.trim(), + }, + wifi_password: { + desc: 'the Wi-Fi PASSWORD / KEY. Labeled WPA, WPA2, WPA Key, Wi-Fi Password, Wireless Password, Clé Wi-Fi, Mot de passe Wi-Fi, Password, Passphrase. Usually 8-20 chars, mixed case with numbers and sometimes symbols.', + clean: v => v.trim(), + }, + imei: { + desc: 'the IMEI (15 digits, exactly). Labeled IMEI.', + clean: v => v.replace(/\D/g, ''), + }, + generic: { + desc: 'the requested value (see context hint below)', + clean: v => v.trim(), + }, +} + +const FIELD_SCHEMA = { + type: 'object', + properties: { + value: { type: 'string', nullable: true }, + confidence: { type: 'number' }, + }, + required: ['value', 'confidence'], +} + +async function extractField (base64Image, field, context = {}) { + const config = FIELD_CONFIG[field] || FIELD_CONFIG.generic + const eq = context.equipment_type ? `Equipment type hint: ${context.equipment_type}.` : '' + const brand = context.brand ? `Brand hint: ${context.brand}.` : '' + const model = context.model ? `Model hint: ${context.model}.` : '' + const custom = (field === 'generic' && context.hint) ? `Look for: ${context.hint}.` : '' + const prompt = `You are reading an ISP equipment label (ONT, router, modem). Extract ${config.desc} +${eq} ${brand} ${model} ${custom} +Return ONLY JSON matching the schema: {"value": "", "confidence": <0.0-1.0>}. +If you cannot find it with confidence above 0.5, return {"value": null, "confidence": 0.0}. +Do NOT invent data. Prefer returning null over guessing.` + const parsed = await geminiVision(base64Image, prompt, FIELD_SCHEMA) + if (!parsed || !parsed.value) return { value: null, confidence: 0 } + const cleaned = config.clean(parsed.value) + if (!cleaned) return { value: null, confidence: 0 } + return { value: cleaned, confidence: Math.max(0, Math.min(1, Number(parsed.confidence) || 0.5)) } +} + +async function handleFieldScan (req, res) { + const body = await parseBody(req) + const check = extractBase64(req, body, 'field-scan') + if (check.error) return json(res, check.status, { error: check.error }) + try { + const out = await extractField(check.base64, body.field || 'generic', { + hint: body.hint, equipment_type: body.equipment_type, brand: body.brand, model: body.model, + }) + return json(res, 200, { ok: true, ...out }) + } catch (e) { + log('Vision field-scan error:', e.message) + return json(res, 500, { error: 'Vision field extraction failed: ' + e.message }) + } +} + +module.exports = { handleBarcodes, extractBarcodes, handleEquipment, handleInvoice, extractField, handleFieldScan }