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
- Tous (${history.length + overdue.length})
- Terminés (${history.filter(j=>j.status==='Completed').length})
+ Tous (${all.length})
+ Terminés (${doneCount})
Manqués (${overdue.length})
- Annulés (${history.filter(j=>j.status==='Cancelled').length})
+ Annulés (${cancCount})
- ${[...overdue, ...history].map(j => jobCard(j, today)).join('') || '
'}
+ ${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 ? `
` : ''}
- ${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) : ''}
-
-
↻ Actualiser les données
+
+ ${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
- Chercher
+ Chercher
Installé sur cette tâche
@@ -754,338 +638,298 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
Catalogue
-
+
`
+}
-
-
-
-
📷 Scanner ✕
-
-
-
-
- Annuler
- 📸 Capturer
-
-
-
+// ═════════════════════════════════════════════════════════════════════════════
+// 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;
-
-
- 📅 Aujourd'hui
- 📆 Calendrier
- 📋 Historique
- 👤 Profil
-
+// ── 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 ``
+}
+
+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 `${label} `
+}
+
+// ── 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 `${esc(t.icon)} ${esc(t.label)} `
+ }).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 + '%
' +
+ '
' +
+ '
' +
+ '✓ Utiliser ' +
+ '↻ Réessayer ' +
+ '
' +
+ '
'
+ }).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 ✕
+
+
+
+
+ Annuler
+ 📸 Capturer
+
+
+
+`
+
+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 (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 (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 ? `` : ''
+ const scannerMarkup = includeScanner ? SCANNER_OVERLAY_HTML : ''
+
+ return `
+
+
+
+
+
+
${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 }