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 `
Aucune tâche active.
Profitez de la pause !
Aucun historique.
La vue calendrier avec navigation mois/semaine arrive à la phase 4.
Pour l'instant, utilisez Aujourd'hui ou Historique.