diff --git a/services/targo-hub/lib/tech-mobile.js b/services/targo-hub/lib/tech-mobile.js index b92d5eb..745110b 100644 --- a/services/targo-hub/lib/tech-mobile.js +++ b/services/targo-hub/lib/tech-mobile.js @@ -3,22 +3,25 @@ const cfg = require('./config') const { log, json, erpFetch } = require('./helpers') const { verifyJwt } = require('./magic-link') const { extractField } = require('./vision') +const ui = require('./ui') // ── Tech mobile SPA ────────────────────────────────────────────────────────── // Server-rendered shell + hash-routed client views (home/hist/cal/profile/job-detail). -// No framework. All jobs pre-loaded and embedded as JSON; detail view pops open -// instantly from the cache and async-refreshes notes / photos / equipment. +// All UI primitives come from ../ui (design.css, components, client.js, scanner.js). +// All jobs pre-loaded and embedded as JSON; detail view pops open instantly from +// the cache and async-refreshes notes / photos / equipment. // // Field-targeted scans: each form field has a 📷 that opens a Gemini-backed // single-field extractor — "find the Wi-Fi password on this label" rather than // "read everything". Tech approves or retries the value before it fills the input. -// ── Auth helpers ───────────────────────────────────────────────────────────── +// ═════════════════════════════════════════════════════════════════════════════ +// Auth + request helpers +// ═════════════════════════════════════════════════════════════════════════════ function authToken (path) { const m = path.match(/^\/t\/([A-Za-z0-9_\-\.]+)/) - if (!m) return null - return verifyJwt(m[1]) + return m ? verifyJwt(m[1]) : null } async function readBody (req) { @@ -27,66 +30,50 @@ 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 ───────────────────────────────────────────────────────── +// ═════════════════════════════════════════════════════════════════════════════ +// 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) + if (method === 'GET' && /^\/t\/[A-Za-z0-9_\-\.]+$/.test(path)) return handlePage(req, res, path) + if (method === 'POST' && path.endsWith('/status')) return handleStatus(req, res, path) + if (method === 'POST' && path.endsWith('/note')) return handleNote(req, res, path) + if (method === 'POST' && path.endsWith('/photo')) return handlePhotoUpload(req, res, path) + if (method === 'GET' && path.endsWith('/photos')) return handlePhotoList(req, res, path) + if (method === 'GET' && path.endsWith('/photo-serve')) return handlePhotoServe(req, res, path) + if (method === 'GET' && path.endsWith('/job')) return handleJobDetail(req, res, path) + if (method === 'POST' && path.endsWith('/field-scan')) return handleFieldScan(req, res, path) + if (method === 'POST' && path.endsWith('/scan')) return handleScan(req, res, path) + if (method === 'POST' && path.endsWith('/vision')) return handleVision(req, res, path) + if (method === 'POST' && path.endsWith('/equip')) return handleEquipInstall(req, res, path) + if (method === 'POST' && path.endsWith('/equip-remove')) return handleEquipRemove(req, res, path) + if (method === 'GET' && path.endsWith('/catalog')) return handleCatalog(req, res, path) + if (method === 'GET' && path.endsWith('/equip-list')) return handleEquipList(req, res, path) return json(res, 404, { error: 'Not found' }) } -// ── Shell + data fetch ─────────────────────────────────────────────────────── +// ═════════════════════════════════════════════════════════════════════════════ +// Page shell + data load +// ═════════════════════════════════════════════════════════════════════════════ async function handlePage (req, res, path) { const payload = authToken(path) - if (!payload) { res.writeHead(200, html()); return res.end(pageExpired()) } + if (!payload) { res.writeHead(200, ui.htmlHeaders()); return res.end(ui.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)) + // Montreal local time — UTC midnight rolls over 4-5 h before Montreal midnight, + // which would mislabel evening jobs as "hier". + const today = ui.montrealDate() + const rearDate = ui.montrealDate(new Date(Date.now() - 60 * 86400 * 1000)) + const frontDate = ui.montrealDate(new Date(Date.now() + 60 * 86400 * 1000)) try { const 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. + // and fields not in DocField. Real time field is `start_time`. We fetch link + // IDs and resolve names in two batch follow-up queries. 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], @@ -95,48 +82,55 @@ async function handlePage (req, res, path) { 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 || '' - } + await resolveJobLabels(jobs) - res.writeHead(200, html()) - res.end(renderPage({ tech, jobs, token: path.split('/')[2], today })) + const token = path.split('/')[2] + res.writeHead(200, ui.htmlHeaders()) + res.end(renderPage({ tech, jobs, token, today })) } catch (e) { log('tech-mobile page error:', e.message) - res.writeHead(500, html()) - res.end(pageError()) + res.writeHead(500, ui.htmlHeaders()) + res.end(ui.pageError()) } } -// ── Status update (reuse dispatch chain walker) ────────────────────────────── +// Batch-fill customer_name + service_location_name without triggering v16's +// fetched-field permission block. +async function resolveJobLabels (jobs) { + 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 = {}, locNames = {} + + if (custIds.length) { + try { + const f = encodeURIComponent(JSON.stringify([['name', 'in', custIds]])) + const r = await erpFetch(`/api/resource/Customer?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'customer_name']))}&limit_page_length=200`) + for (const c of (r.data?.data || [])) custNames[c.name] = c.customer_name || c.name + } catch { /* non-fatal */ } + } + if (locIds.length) { + try { + const f = encodeURIComponent(JSON.stringify([['name', 'in', locIds]])) + const r = await erpFetch(`/api/resource/Service%20Location?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'address_line_1', 'city']))}&limit_page_length=200`) + for (const l of (r.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 || '' + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// API handlers (unchanged behavior — just cleaner layout) +// ═════════════════════════════════════════════════════════════════════════════ async function handleStatus (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.job || !body?.status) return json(res, 400, { error: 'job and status required' }) - const allowed = ['In Progress', 'Completed'] - if (!allowed.includes(body.status)) return json(res, 400, { error: 'Invalid status' }) + if (!['In Progress', 'Completed'].includes(body.status)) return json(res, 400, { error: 'Invalid status' }) try { const { setJobStatusWithChain } = require('./dispatch') const result = await setJobStatusWithChain(body.job, body.status) @@ -144,8 +138,6 @@ async function handleStatus (req, res, path) { } 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' }) @@ -153,16 +145,13 @@ async function handleNote (req, res, path) { 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 || '' }), + 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' }) @@ -175,12 +164,8 @@ async function handlePhotoUpload (req, res, path) { 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, + 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' }) @@ -206,8 +191,7 @@ async function handlePhotoList (req, res, path) { } 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. +// Proxy a private file so the tech browser can render without ERPNext creds. async function handlePhotoServe (req, res, path) { const payload = authToken(path) if (!payload) { res.writeHead(401); return res.end() } @@ -232,8 +216,6 @@ async function handlePhotoServe (req, res, path) { } } -// ── 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' }) @@ -247,8 +229,6 @@ async function handleJobDetail (req, res, path) { } 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' }) @@ -257,10 +237,8 @@ async function handleFieldScan (req, res, path) { 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, + hint: body.hint, equipment_type: body.equipment_type, + brand: body.brand, model: body.model, }) return json(res, 200, { ok: true, ...out }) } catch (e) { @@ -269,8 +247,6 @@ async function handleFieldScan (req, res, path) { } } -// ── Barcode lookup in ERPNext ─────────────────────────────────────────────── - async function handleScan (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) @@ -303,8 +279,6 @@ async function handleVision (req, res, path) { } 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' }) @@ -318,13 +292,9 @@ async function handleEquipInstall (req, res, path) { 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 || '', + request: job || '', barcode: barcode || '', + equipment_type: equipment_type || '', brand: brand || '', model: model || '', notes: [ notes || '', mac_address ? `MAC: ${mac_address}` : '', @@ -332,13 +302,10 @@ async function handleEquipInstall (req, res, path) { wifi_ssid ? `SSID: ${wifi_ssid}` : '', wifi_password ? `Wi-Fi PWD: ${wifi_password}` : '', ].filter(Boolean).join(' | '), - installation_date: today, - technician: payload.sub, - action: action || 'install', + 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() @@ -346,17 +313,11 @@ async function handleEquipInstall (req, res, path) { 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 || '', + 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)}`, { @@ -407,11 +368,11 @@ async function handleCatalog (req, res, path) { 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' }, + { 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 }) @@ -433,249 +394,174 @@ async function handleEquipList (req, res, path) { } // ═════════════════════════════════════════════════════════════════════════════ -// HTML +// Server-side HTML rendering (uses ../ui primitives) // ═════════════════════════════════════════════════════════════════════════════ -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, ''') } -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 `${esc(m.label)}` -} - 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 urgent = j.priority === 'urgent' || j.priority === 'high' + const done = j.status === 'Completed' || j.status === 'Cancelled' + const inProg = j.status === 'In Progress' || j.status === 'in_progress' + const border = urgent ? 'var(--danger)' : done ? 'var(--text-dim)' : inProg ? 'var(--warning)' : 'var(--brand)' const overdue = !done && j.scheduled_date && j.scheduled_date < today - const dlbl = dateLabelFr(j.scheduled_date, today) - const tlbl = j.start_time ? fmtTime(j.start_time) : '' + const dlbl = ui.dateLabelFr(j.scheduled_date, today) + const tlbl = j.start_time ? ui.fmtTime(j.start_time) : '' const datePart = [dlbl, tlbl].filter(Boolean).join(' · ') - 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 ? ' 🔥' : ''}
-
` + + const inner = ` +
+ ${ui.esc(j.name)} + ${ui.badge(j.status)} + ${ui.esc(datePart)} +
+
${ui.esc(j.subject || 'Sans titre')}
+ ${j.customer_name ? `
👤 ${ui.esc(j.customer_name)}
` : ''} + ${j.service_location_name ? `
📍 ${ui.esc(j.service_location_name)}
` : ''} +
${j.duration_h ? `⏱ ${j.duration_h}h` : ''} ${j.job_type ? ui.esc(j.job_type) : ''}${urgent ? ' 🔥' : ''}
` + + return ui.card(inner, { + extraClass: (done ? 'dim ' : '') + (overdue ? 'od' : ''), + onclick: `go('#job/${ui.esc(j.name)}')`, + style: `border-left:4px solid ${border}`, + }) } function renderPage ({ tech, jobs, token, today }) { const techName = tech.full_name || tech.name + // Partition for the home view — rest is client-side filtering const inProgress = jobs.filter(j => 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 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 jobsMap = Object.fromEntries(jobs.map(j => [j.name, j])) - const sec = (title, list, mod = '') => list.length - ? `
${title} ${list.length}
${list.map(j => jobCard(j, today)).join('')}` - : '' + const body = ` +${renderHome({ techName, inProgress, overdue, todayJobs, nodate, upcoming, activeCount, historyCount: history.length, today })} +${renderHist({ history, overdue, today })} +${renderCal()} +${renderProfile({ tech, techName })} +
+${renderEquipOverlay()} +${ui.tabBar([ + { id: 'home', label: "Aujourd'hui", icon: '📅', active: true }, + { id: 'cal', label: 'Calendrier', icon: '📆' }, + { id: 'hist', label: 'Historique', icon: '📋' }, + { id: 'profile', label: 'Profil', icon: '👤' }, +])} + ` - return ` - -Mes tâches — Gigafibre - +.cat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px} +.cat-card{background:var(--surface);border:2px solid #e5e7eb;border-radius:10px;padding:12px;text-align:center;cursor:pointer;transition:all var(--t-fast)} +.cat-card:active{border-color:var(--brand);background:var(--brand-soft)} +.cat-card .cn{font-size:13px;font-weight:700;color:#1e293b;margin-bottom:2px} +.cat-card .cd{font-size:10px;color:var(--text-dim)} +.cat-card .cp{font-size:13px;font-weight:700;color:var(--brand);margin-top:4px} +.inp-row{display:flex;gap:8px;margin-bottom:10px} +.inp-row .inp{flex:1} +`, + body, + script: CLIENT_SCRIPT, + }) +} - -
+// ═════════════════════════════════════════════════════════════════════════════ +// View renderers (home / hist / cal / profile / overlays) +// ═════════════════════════════════════════════════════════════════════════════ + +function renderHome ({ techName, inProgress, overdue, todayJobs, nodate, upcoming, activeCount, historyCount, today }) { + const isEmpty = activeCount === 0 && inProgress.length === 0 + const cards = (list) => list.map(j => jobCard(j, today)) + + return `
-
${esc(todayFr())}
-
👷${esc(techName)}
-
-
${activeCount}À FAIRE
-
${inProgress.length}EN COURS
-
${history.length}TERMINÉS
-
+
${ui.esc(ui.todayFr())}
+
👷${ui.esc(techName)}
+ ${ui.statRow([ + { value: activeCount, label: 'À FAIRE', id: 'stActive' }, + { value: inProgress.length, label: 'EN COURS' }, + { value: historyCount, label: '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)} + ${isEmpty ? ui.emptyState('🎉', 'Aucune tâche active.
Profitez de la pause !') : ''} + ${ui.section('En cours', cards(inProgress))} + ${ui.section('En retard', cards(overdue), 'danger')} + ${ui.section("Aujourd'hui", cards(todayJobs))} + ${ui.section('Sans date', cards(nodate))} + ${ui.section('À venir', cards(upcoming))}
-
+
` +} - -
+function renderHist ({ history, overdue, today }) { + const all = [...overdue, ...history] + const doneCount = history.filter(j => j.status === 'Completed').length + const cancCount = history.filter(j => j.status === 'Cancelled').length + + return `
Historique
📋Tâches passées
@@ -683,61 +569,59 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
- - + + - +
- ${[...overdue, ...history].map(j => jobCard(j, today)).join('') || '
📭

Aucun historique.

'} + ${all.length ? all.map(j => jobCard(j, today)).join('') : ui.emptyState('📭', 'Aucun historique.')}
-
+
` +} - -
+function renderCal () { + return `
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.

-
+ ${ui.placeholder('🚧', 'Bientôt disponible', `La vue calendrier avec navigation mois/semaine arrive à la phase 4.
Pour l'instant, utilisez Aujourd'hui ou Historique.`)}
-
+
` +} - -
+function renderProfile ({ tech, techName }) { + const line = (icon, text, link = '') => + `
${icon}${ui.esc(text)}${link}
` + return `
Profil
-
👤${esc(techName)}
+
👤${ui.esc(techName)}

Informations

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

Support

-
📞Ligne support438-231-3838
+ ${line('📞', 'Ligne support', '438-231-3838')}
-
- +
+ ${ui.button('↻ Actualiser les données', { kind: 'muted', block: true, onclick: 'location.reload()' })}
-
+
` +} - -
- - -
+function renderEquipOverlay () { + return `

Équipement

@@ -746,7 +630,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
- +
Installé sur cette tâche
@@ -754,338 +638,298 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
Catalogue
Chargement…
-
+
` +} - -
-
-

📷Scanner

-
- -
-
- - -
-
-
+// ═════════════════════════════════════════════════════════════════════════════ +// Client-side logic (consumes window.api / window.router / window.scanner / etc.) +// ═════════════════════════════════════════════════════════════════════════════ -
+const CLIENT_SCRIPT = ` +// Current detail-view job, customer, location (set by openDetail) +var CJ='',CC='',CL='',CMODEL='',CTYPE=''; +// Equipment overlay scanner (separate from field-scan) +var stream=null, bdTimer=null; - -
- - - - -
+// ── Hash routes ────────────────────────────────────────────────────────── +router.on('#home', function(){ showTab('home') }); +router.on('#cal', function(){ showTab('cal') }); +router.on('#hist', function(){ showTab('hist') }); +router.on('#profile', function(){ showTab('profile') }); +router.on('#job/:name', function(p){ openDetail(p.name) }); +router.dispatch(); -` -} - -function pageExpired () { - return `Lien expiré - -
🔗

Lien expiré

Ce lien n'est plus valide. Votre superviseur vous enverra un nouveau lien par SMS.

` -} - -function pageError () { - return `Erreur - -

Erreur temporaire

Réessayez dans quelques instants.

` -} +` module.exports = { route } diff --git a/services/targo-hub/lib/ui/client.js b/services/targo-hub/lib/ui/client.js new file mode 100644 index 0000000..b8d7f7f --- /dev/null +++ b/services/targo-hub/lib/ui/client.js @@ -0,0 +1,131 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Shared client-side JS baked into every magic-link page. +// Exposes: $, esc, toast, fmtTime, dlbl, api, router, go +// Configure via window.UI_CFG = { token, hub, today, base } BEFORE this loads. +// ───────────────────────────────────────────────────────────────────────────── +(function () { + var CFG = window.UI_CFG || {} + var HUB = CFG.hub || '' + var BASE = CFG.base || '' // e.g. "/t/" — prepended to all api paths + + // ── DOM / string ──────────────────────────────────────────────────────── + function $ (id) { return document.getElementById(id) } + function esc (s) { + return (s == null ? '' : String(s)) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, ''') + } + + // ── Toast ─────────────────────────────────────────────────────────────── + function toast (message, ok) { + var t = $('toast') + if (!t) { + t = document.createElement('div') + t.id = 'toast' + t.className = 'toast' + document.body.appendChild(t) + } + t.textContent = message + t.style.background = ok ? '#22c55e' : '#ef4444' + t.classList.add('on') + clearTimeout(t._timer) + t._timer = setTimeout(function () { t.classList.remove('on') }, 2500) + } + + // ── Date / time (mirror of server helpers) ────────────────────────────── + function fmtTime (t) { + if (!t) return '' + var p = String(t).split(':') + return p[0] + 'h' + (p[1] || '00') + } + function dlbl (d, today) { + today = today || CFG.today + 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 } + } + + // ── API wrapper ───────────────────────────────────────────────────────── + // Single place to prepend HUB+BASE and parse JSON. Page-specific code + // just does api.post('/note', {job, notes}) — no URL glue, no error swallowing. + function apiUrl (path) { + // Allow absolute paths through unchanged (e.g. fully qualified URLs) + if (/^https?:/i.test(path)) return path + if (path[0] !== '/') path = '/' + path + return HUB + BASE + path + } + function apiFetch (path, opts) { + opts = opts || {} + var headers = Object.assign({}, opts.headers || {}) + if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) { + headers['Content-Type'] = headers['Content-Type'] || 'application/json' + opts.body = JSON.stringify(opts.body) + } + opts.headers = headers + return fetch(apiUrl(path), opts).then(function (r) { + var ct = r.headers.get('content-type') || '' + if (ct.indexOf('application/json') >= 0) { + return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data } }) + } + return r.text().then(function (text) { return { ok: r.ok, status: r.status, text: text } }) + }) + } + var api = { + url: apiUrl, + get: function (p) { return apiFetch(p) }, + post: function (p, body){ return apiFetch(p, { method: 'POST', body: body }) }, + put: function (p, body){ return apiFetch(p, { method: 'PUT', body: body }) }, + } + + // ── Hash router ───────────────────────────────────────────────────────── + // Patterns: "#home" (literal) or "#job/:name" (param). Handlers are called + // with (params, rawHash). Calling router.go('#foo') updates location.hash. + var routes = [] + function on (pattern, handler) { routes.push({ pattern: pattern, handler: handler }) } + function matchRoute (hash) { + for (var i = 0; i < routes.length; i++) { + var r = routes[i] + if (r.pattern === hash) return { handler: r.handler, params: {} } + // "#job/:name" — matches "#job/DJ-123" + var parts = r.pattern.split('/') + var actual = hash.split('/') + if (parts.length !== actual.length) continue + var params = {}, ok = true + for (var j = 0; j < parts.length; j++) { + if (parts[j][0] === ':') params[parts[j].slice(1)] = decodeURIComponent(actual[j] || '') + else if (parts[j] !== actual[j]) { ok = false; break } + } + if (ok) return { handler: r.handler, params: params } + } + return null + } + function dispatch () { + var hash = location.hash || (routes[0] && routes[0].pattern) || '#' + var m = matchRoute(hash) + if (m) { m.handler(m.params, hash) } + else if (routes[0]) { routes[0].handler({}, routes[0].pattern) } + window.scrollTo(0, 0) + } + function go (hash) { + if (location.hash === hash) dispatch() + else location.hash = hash + } + window.addEventListener('hashchange', dispatch) + + var router = { on: on, dispatch: dispatch, go: go } + + // ── Expose globals (only ones pages reference directly) ───────────────── + window.$ = $ + window._esc = esc // underscore to avoid colliding with page esc + window.toast = toast + window.fmtTime = fmtTime + window.dlbl = dlbl + window.api = api + window.router = router + window.go = go +})() diff --git a/services/targo-hub/lib/ui/components.js b/services/targo-hub/lib/ui/components.js new file mode 100644 index 0000000..c416fbb --- /dev/null +++ b/services/targo-hub/lib/ui/components.js @@ -0,0 +1,134 @@ +'use strict' +// ───────────────────────────────────────────────────────────────────────────── +// Server-side HTML fragment builders for magic-link pages. +// Pure functions returning strings. Pairs with ui/design.css classes. +// ───────────────────────────────────────────────────────────────────────────── + +function esc (s) { + return (s == null ? '' : String(s)) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, ''') +} + +// Safely inline JSON in a + --> breakouts) +function jsonScript (value) { + return JSON.stringify(value).replace(//g, '--\\u003e') +} + +// ── Canonical status metadata ───────────────────────────────────────────── +// One source of truth for FR labels + colors. Both tech mobile and detail +// views read from this so a status rename propagates everywhere. +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 statusMeta (s) { + return STATUS_META[s] || { label: s || '—', color: '#94a3b8' } +} + +function badge (status) { + const m = statusMeta(status) + return `${esc(m.label)}` +} + +// ── Section with count pill ─────────────────────────────────────────────── +// title: human heading, e.g. "En retard" +// items: array of pre-rendered HTML strings (from jobCard() or similar) +// modifier: 'danger' | '' — adds .sec.danger for red accent +function section (title, items, modifier = '') { + if (!items.length) return '' + const mod = modifier ? ' ' + modifier : '' + return `
${esc(title)} ${items.length}
${items.join('')}` +} + +// ── Primitive wrappers ──────────────────────────────────────────────────── +function card (inner, { onclick, extraClass = '', style = '' } = {}) { + const cls = 'card' + (extraClass ? ' ' + extraClass : '') + const click = onclick ? ` onclick="${onclick}"` : '' + const st = style ? ` style="${style}"` : '' + return `
${inner}
` +} + +function panel (title, bodyHtml, { extra = '' } = {}) { + return `

${esc(title)}${extra ? ` ${extra}` : ''}

${bodyHtml}
` +} + +function emptyState (emoji, message) { + return `
${esc(emoji)}

${message}

` +} + +function placeholder (emoji, title, body) { + return `
${esc(emoji)}

${esc(title)}

${body}

` +} + +function button (label, { kind = 'pri', onclick, id, block = false, extraClass = '', disabled = false, style = '' } = {}) { + const cls = ['btn', 'btn-' + kind, block ? 'btn-block' : '', extraClass].filter(Boolean).join(' ') + return `` +} + +// ── Stat row (inside header) ────────────────────────────────────────────── +// items: [{ value, label, onclick? }] +function statRow (items) { + return `
${items.map(it => { + const click = it.onclick ? ` onclick="${it.onclick}"` : '' + const id = it.id ? ` id="${it.id}"` : '' + return `
${esc(it.value)}${esc(it.label)}
` + }).join('')}
` +} + +// ── Tab bar ─────────────────────────────────────────────────────────────── +// tabs: [{ id, label, icon, active? }] +function tabBar (tabs) { + return `
${tabs.map(t => { + const cls = 'tab' + (t.active ? ' on' : '') + return `` + }).join('')}
` +} + +// ── Date / time helpers ─────────────────────────────────────────────────── +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 } +} + +// "Aujourd'hui" / "Hier" / "Demain" / weekday / date +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' }) +} + +// 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' }) +} + +module.exports = { + esc, jsonScript, + STATUS_META, statusMeta, badge, + section, card, panel, emptyState, placeholder, button, statRow, tabBar, + fmtTime, fmtDate, dateLabelFr, todayFr, montrealDate, +} diff --git a/services/targo-hub/lib/ui/design.css b/services/targo-hub/lib/ui/design.css new file mode 100644 index 0000000..a847c54 --- /dev/null +++ b/services/targo-hub/lib/ui/design.css @@ -0,0 +1,364 @@ +/* ───────────────────────────────────────────────────────────────────────────── + Targo design tokens + primitives + Used by every magic-link page served from targo-hub (tech, acceptance, pay). + One place to tweak brand colors, spacing, radii, shadows. + ───────────────────────────────────────────────────────────────────────────── */ + +:root { + /* Brand */ + --brand: #5c59a8; + --brand-dark: #3f3d7a; + --brand-soft: #eef2ff; + --brand-tint: #ddd8ff; + + /* Semantic status */ + --success: #22c55e; + --success-dark: #16a34a; + --success-soft: #dcfce7; + --warning: #f59e0b; + --warning-dark: #d97706; + --warning-soft: #fef3c7; + --danger: #ef4444; + --danger-dark: #dc2626; + --danger-soft: #fee2e2; + --info: #818cf8; + --info-soft: #e0e7ff; + + /* Neutrals */ + --bg: #f1f5f9; + --surface: #fff; + --surface-alt: #f8fafc; + --border: #e2e8f0; + --border-soft: #f1f5f9; + --text: #0f172a; + --text-muted: #64748b; + --text-dim: #94a3b8; + + /* Motion */ + --tap-scale: .985; + --t-fast: .15s; + --t-med: .25s; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; } + +html, body { min-height: 100%; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + -webkit-text-size-adjust: 100%; + line-height: 1.4; +} + +/* ── Header (brand gradient) ───────────────────────────────────────────── */ +.hdr { + background: linear-gradient(135deg, var(--brand-dark), var(--brand)); + 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; +} + +/* ── Stat row inside header ────────────────────────────────────────────── */ +.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 var(--t-fast); +} +.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; } + +/* ── Page wrap ─────────────────────────────────────────────────────────── */ +.wrap { padding: 14px 12px; margin-top: -14px; } + +/* ── Section heading ───────────────────────────────────────────────────── */ +.sec { + font-size: 11px; font-weight: 700; color: var(--brand); + letter-spacing: 1.2px; margin: 16px 4px 8px; text-transform: uppercase; + display: flex; align-items: center; gap: 8px; +} +.sec .cnt { + background: var(--brand-tint); color: var(--brand); + padding: 1px 7px; border-radius: 10px; font-size: 10px; +} +.sec.danger { color: var(--danger); } +.sec.danger .cnt { background: var(--danger-soft); color: var(--danger); } + +/* ── Card ──────────────────────────────────────────────────────────────── */ +.card { + background: var(--surface); + border-radius: 12px; + padding: 12px; + margin-bottom: 10px; + box-shadow: 0 1px 2px rgba(15,23,42,.06); + transition: transform .1s; +} +.card[onclick], .card.tappable { cursor: pointer; } +.card[onclick]:active, .card.tappable:active { transform: scale(var(--tap-scale)); } +.card.dim { opacity: .6; } +.card.od { box-shadow: 0 1px 2px rgba(239,68,68,.15), 0 0 0 1px #fecaca; } + +/* ── Badge ─────────────────────────────────────────────────────────────── */ +.bdg { + padding: 2px 8px; border-radius: 8px; + font-size: 10px; font-weight: 700; letter-spacing: .2px; + display: inline-block; +} + +/* ── Row ───────────────────────────────────────────────────────────────── */ +.row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + +/* ── Inputs ────────────────────────────────────────────────────────────── */ +.inp { + padding: 10px 12px; + border: 1.5px solid var(--border); + border-radius: 10px; + font-size: 14px; + background: var(--surface); + font-family: inherit; + outline: none; + width: 100%; +} +.inp:focus { border-color: var(--brand); } +textarea.inp { resize: vertical; min-height: 90px; } + +/* ── Buttons ───────────────────────────────────────────────────────────── */ +.btn { + padding: 12px 16px; border: none; border-radius: 10px; + font-family: inherit; font-weight: 700; font-size: 14px; + cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px; + transition: background var(--t-fast), transform .1s; +} +.btn:active { transform: scale(var(--tap-scale)); } +.btn:disabled { opacity: .5; pointer-events: none; } +.btn-pri { background: var(--brand); color: #fff; } +.btn-pri:active { background: var(--brand-dark); } +.btn-ok { background: var(--success); color: #fff; } +.btn-ok:active { background: var(--success-dark); } +.btn-warn { background: var(--warning); color: #fff; } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:active { background: var(--danger-dark); } +.btn-ghost { background: var(--brand-soft); color: var(--brand); } +.btn-ghost:active { background: var(--brand-tint); } +.btn-muted { background: var(--border-soft); color: var(--text-muted); } +.btn-block { width: 100%; } +.btn-sm { padding: 9px 12px; font-size: 13px; border-radius: 8px; } + +/* ── Empty state ───────────────────────────────────────────────────────── */ +.empty { + text-align: center; + padding: 50px 24px; + color: var(--text-dim); +} +.empty .em { font-size: 44px; margin-bottom: 8px; opacity: .5; } + +.placeholder { + background: var(--surface); + border-radius: 12px; + padding: 40px 24px; + text-align: center; + color: var(--text-dim); + 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; } + +/* ── 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 var(--t-med); + pointer-events: none; + box-shadow: 0 4px 12px rgba(0,0,0,.2); +} +.toast.on { opacity: 1; transform: translateY(0); } + +/* ── Sticky search bar ─────────────────────────────────────────────────── */ +.sbar { + position: sticky; top: 0; + background: var(--bg); + padding: 10px 4px 8px; + z-index: 5; + margin: 0 -4px; +} + +/* ── Panel (detail-view section) ───────────────────────────────────────── */ +.panel { + background: var(--surface); + 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: var(--brand); + 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: var(--text-dim); + font-weight: 600; letter-spacing: .5px; +} + +/* ── Bottom tab bar ────────────────────────────────────────────────────── */ +.tbar { + position: fixed; bottom: 0; left: 0; right: 0; + background: var(--surface); + border-top: 1px solid var(--border); + 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: var(--text-dim); + font-size: 10px; font-weight: 600; + cursor: pointer; border: none; background: none; + font-family: inherit; +} +.tab .ic { font-size: 22px; line-height: 1; } +.tab.on { color: var(--brand); } + +/* ── Overlay (fullscreen pane over page) ───────────────────────────────── */ +.ov { + display: none; + position: fixed; inset: 0; + background: var(--bg); + z-index: 100; + flex-direction: column; + overflow-y: auto; +} +.ov.open { display: flex; } +.ov-hdr { + display: flex; align-items: center; + padding: 12px 14px; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 2; + gap: 8px; +} +.ov-hdr h2 { font-size: 16px; font-weight: 700; flex: 1; margin: 0; color: var(--text); } +.ov-x { + background: none; border: none; + font-size: 22px; cursor: pointer; padding: 4px 8px; color: var(--text-muted); + width: 36px; height: 36px; + display: flex; align-items: center; justify-content: center; + border-radius: 8px; +} +.ov-x:active { background: var(--bg); } +.ov-body { padding: 14px; flex: 1; } +.ov-sec { + font-size: 11px; font-weight: 700; color: var(--brand); + letter-spacing: 1.2px; margin: 16px 0 8px; text-transform: uppercase; +} + +/* ── Scanner camera + results ──────────────────────────────────────────── */ +.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; +} +.sr { + background: var(--surface); + 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: var(--brand); } +.sr .sm { font-size: 11px; color: var(--text-dim); } + +/* ── Field-scan nested overlay (single-value Gemini) ───────────────────── */ +.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: var(--surface); + 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: var(--text); + display: flex; align-items: center; gap: 8px; +} +.fs-box .fs-close { + margin-left: auto; + background: none; border: none; + font-size: 22px; color: var(--text-muted); + 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-family: inherit; font-weight: 700; font-size: 14px; + cursor: pointer; +} +.fs-capture { background: var(--brand); color: #fff; } +.fs-capture:active { background: var(--brand-dark); } +.fs-cancel { background: var(--border-soft); color: var(--text-muted); } +.conf-bar { + height: 4px; background: var(--border-soft); + 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: var(--text); + margin: 10px 0 4px; text-align: center; + font-family: ui-monospace, monospace; +} + +/* ── Utility ───────────────────────────────────────────────────────────── */ +.dis { opacity: .5; pointer-events: none; } +.hidden { display: none !important; } +.ta-c { text-align: center; } +.ta-r { text-align: right; } +.mt-0 { margin-top: 0; } .mt-1 { margin-top: 8px; } .mt-2 { margin-top: 16px; } +.mb-0 { margin-bottom: 0; } .mb-1 { margin-bottom: 8px; } .mb-2 { margin-bottom: 16px; } +.pad-body { padding-bottom: 68px; min-height: 100vh; } /* reserve space for tbar */ diff --git a/services/targo-hub/lib/ui/index.js b/services/targo-hub/lib/ui/index.js new file mode 100644 index 0000000..238321e --- /dev/null +++ b/services/targo-hub/lib/ui/index.js @@ -0,0 +1,39 @@ +'use strict' +// ───────────────────────────────────────────────────────────────────────────── +// Barrel export for the magic-link UI kit. +// const ui = require('../ui') +// ui.page({ ... }) +// ui.badge('In Progress') +// ui.section('En retard', cards, 'danger') +// ───────────────────────────────────────────────────────────────────────────── +const shell = require('./shell') +const comp = require('./components') + +module.exports = { + // shell + page: shell.page, + pageExpired: shell.pageExpired, + pageError: shell.pageError, + html: shell.html, + htmlHeaders: shell.htmlHeaders, + + // components + esc: comp.esc, + jsonScript: comp.jsonScript, + STATUS_META: comp.STATUS_META, + statusMeta: comp.statusMeta, + badge: comp.badge, + section: comp.section, + card: comp.card, + panel: comp.panel, + emptyState: comp.emptyState, + placeholder: comp.placeholder, + button: comp.button, + statRow: comp.statRow, + tabBar: comp.tabBar, + fmtTime: comp.fmtTime, + fmtDate: comp.fmtDate, + dateLabelFr: comp.dateLabelFr, + todayFr: comp.todayFr, + montrealDate: comp.montrealDate, +} diff --git a/services/targo-hub/lib/ui/scanner.js b/services/targo-hub/lib/ui/scanner.js new file mode 100644 index 0000000..57ac21b --- /dev/null +++ b/services/targo-hub/lib/ui/scanner.js @@ -0,0 +1,113 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Field-targeted scanner overlay (Gemini single-value extraction). +// Opt-in: pages include this when ui.page({ includeScanner: true }). +// +// Usage from page code: +// scanner.open('serial_number', 'Numéro de série', function (value) { +// // value is confirmed by the tech; write it into the target input. +// }, { equipment_type, brand, model }) +// +// The page must also include
markup, which shell.js injects +// automatically when includeScanner is on. +// ───────────────────────────────────────────────────────────────────────────── +(function () { + var stream = null + var field = '' + var fieldLabel = '' + var ctx = {} + var callback = null + + function $ (id) { return document.getElementById(id) } + function esc (s) { return window._esc(s) } + + function open (fieldKey, label, cb, context) { + field = fieldKey; fieldLabel = label; callback = cb; ctx = context || {} + $('fsLabel').textContent = 'Scanner : ' + label + $('fsResult').innerHTML = '
Cadrez l\'étiquette puis appuyez sur Capturer
' + $('fsOv').classList.add('open') + startCam() + } + + function close () { + $('fsOv').classList.remove('open') + stopCam() + callback = null + } + + function startCam () { + var v = $('fsVid') + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + v.style.display = 'none' + $('fsResult').innerHTML = '
Caméra indisponible
' + 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' + }).catch(function (e) { + $('fsResult').innerHTML = '
Caméra refusée: ' + (e.message || '') + '
' + }) + } + function stopCam () { + if (stream) { stream.getTracks().forEach(function (t) { t.stop() }); stream = null } + var v = $('fsVid'); if (v) v.srcObject = null + } + + function capture () { + var v = $('fsVid'), c = $('fsCnv') + if (!v.videoWidth) { window.toast('Caméra non prête', false); return } + c.width = v.videoWidth; c.height = v.videoHeight + var cctx = c.getContext('2d'); cctx.drawImage(v, 0, 0, c.width, c.height) + var b64 = c.toDataURL('image/jpeg', 0.85) + + $('fsResult').innerHTML = '
🤖 Analyse par Gemini Vision…
' + + var payload = { image: b64, field: field } + if (ctx.equipment_type) payload.equipment_type = ctx.equipment_type + if (ctx.brand) payload.brand = ctx.brand + if (ctx.model) payload.model = ctx.model + if (ctx.hint) payload.hint = ctx.hint + + window.api.post('/field-scan', payload).then(function (r) { + var d = r.data || {} + if (!d.ok || !d.value) { + $('fsResult').innerHTML = '
❌ Non détecté — rapprochez-vous et réessayez.
' + return + } + var pct = Math.round((d.confidence || 0) * 100) + var color = pct >= 70 ? '#22c55e' : pct >= 40 ? '#f59e0b' : '#ef4444' + $('fsResult').innerHTML = + '
' + + '
' + esc(d.value) + '
' + + '
Confiance : ' + pct + '%
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + }).catch(function (e) { + $('fsResult').innerHTML = '
Erreur réseau : ' + (e.message || '') + '
' + }) + } + + function confirm (value) { + if (callback) callback(value) + close() + } + + // Bind close button + ESC + window.addEventListener('load', function () { + var x = document.querySelector('#fsOv .fs-close') + if (x) x.addEventListener('click', close) + var cap = document.querySelector('#fsOv .fs-capture') + if (cap) cap.addEventListener('click', capture) + var can = document.querySelector('#fsOv .fs-cancel') + if (can) can.addEventListener('click', close) + }) + + window.scanner = { open: open, close: close, capture: capture, confirm: confirm } +})() diff --git a/services/targo-hub/lib/ui/shell.js b/services/targo-hub/lib/ui/shell.js new file mode 100644 index 0000000..0c71617 --- /dev/null +++ b/services/targo-hub/lib/ui/shell.js @@ -0,0 +1,132 @@ +'use strict' +// ───────────────────────────────────────────────────────────────────────────── +// page() — HTML shell builder. +// Inlines ui/design.css + ui/client.js (+ optionally scanner.js) into every +// magic-link page so the client loads with zero extra requests and a server +// crash can't leave a page partially styled. +// ───────────────────────────────────────────────────────────────────────────── + +const fs = require('fs') +const path = require('path') + +// Read assets once at module load. Changes on disk require a service restart. +const DESIGN_CSS = fs.readFileSync(path.join(__dirname, 'design.css'), 'utf8') +const CLIENT_JS = fs.readFileSync(path.join(__dirname, 'client.js'), 'utf8') +const SCANNER_JS = fs.readFileSync(path.join(__dirname, 'scanner.js'), 'utf8') + +// Nested field-scan overlay markup — injected when includeScanner is true. +const SCANNER_OVERLAY_HTML = ` +
+
+

📷Scanner

+
+ +
+
+ + +
+
+
+` + +const HTML_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store' } + +function htmlHeaders () { return HTML_HEADERS } + +// Safe JSON embed — same as components.jsonScript but avoid circular dep. +function jsonEmbed (v) { return JSON.stringify(v).replace(//g, '--\\u003e') } + +// ───────────────────────────────────────────────────────────────────────── +// page(opts) +// +// title: + og title +// themeColor: optional meta theme-color (default brand-dark) +// lang: default 'fr' +// head: extra HTML injected in <head> (meta, link, etc.) +// body: page body HTML (required) +// bootVars: object; each key becomes a top-level const in script, +// e.g. { T: token, TODAY: 'yyyy-mm-dd' } ⇒ const T=...; const TODAY=... +// cfg: object merged into window.UI_CFG for client.js +// { token, hub, today, base } +// script: page-specific JS (appended after client.js / scanner.js) +// includeScanner: inject scanner.js + overlay markup +// bodyClass: extra class on <body> (e.g. 'pad-body' to reserve tab-bar space) +// +// Returns a complete HTML document string. +// ───────────────────────────────────────────────────────────────────────── +function page (opts) { + const { + title = 'Targo', + themeColor = '#3f3d7a', + lang = 'fr', + head = '', + body = '', + bootVars = {}, + cfg = {}, + script = '', + includeScanner = false, + bodyClass = '', + } = opts + + // bootVars → "const K=v;" lines; JSON-safe, no user escaping needed. + const bootLines = Object.keys(bootVars).map(k => `var ${k}=${jsonEmbed(bootVars[k])};`).join('\n') + const cfgLine = `window.UI_CFG=${jsonEmbed(cfg)};` + + const scannerAssets = includeScanner ? `<script>${SCANNER_JS}</script>` : '' + const scannerMarkup = includeScanner ? SCANNER_OVERLAY_HTML : '' + + return `<!DOCTYPE html> +<html lang="${lang}"> +<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="${themeColor}"> +<title>${title} + +${head} + + +${body} +${scannerMarkup} +
+ + +${scannerAssets} + + +` +} + +// ── Pre-baked error pages ──────────────────────────────────────────────── +function pageExpired (message) { + const msg = message || `Ce lien n'est plus valide. Votre superviseur vous enverra un nouveau lien par SMS.` + return page({ + title: 'Lien expiré', + body: `
+
+
🔗
+

Lien expiré

+

${msg}

+
+
`, + }) +} + +function pageError (message) { + const msg = message || 'Réessayez dans quelques instants.' + return page({ + title: 'Erreur', + body: `
+
+

Erreur temporaire

+

${msg}

+
+
`, + }) +} + +module.exports = { page, pageExpired, pageError, htmlHeaders, html: htmlHeaders }