Introduces services/targo-hub/lib/ui/ as the shared kit for every magic-link
page served from the hub (tech mobile, acceptance, payments):
design.css tokens (--brand, --success, etc) + reset + all primitives
components.js server-side HTML builders (badge/section/card/panel/statRow/
tabBar) + shared date helpers (fmtTime/dateLabelFr/montrealDate)
+ canonical STATUS_META
client.js client-side api wrapper ($, toast, api.get/post, router.on/go)
baked into every page — no more hand-rolled fetch+hashchange
scanner.js Gemini field-scan overlay (window.scanner.open(field,label,cb,ctx))
shell.js ui.page({title, body, bootVars, cfg, script, includeScanner})
inlines everything into one self-contained HTML doc
index.js barrel
Migrates tech-mobile.js to the kit:
- drops inline esc/toast/fmtTime/dlbl/STATUS_META/badge helpers
- api.post('/status', {...}) instead of fetch(H+'/t/'+T+'/status', {...})
- router.on('#job/:name', handler) instead of hand-rolled route()
- scanner.open(field, label, cb, ctx) instead of ~60 lines of field-scan logic
Behavior preserved — rendered HTML keeps tabs, detail view, notes editor,
photo upload, per-field Gemini scans, Montreal-TZ date labels, v16 link-label
resolution. Verified live at msg.gigafibre.ca with a real TECH-4 token.
Sets up acceptance.js and payments.js to drop from ~700 → ~300 lines each
in the next commits by consuming the same primitives.
135 lines
6.4 KiB
JavaScript
135 lines
6.4 KiB
JavaScript
'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, '"').replace(/'/g, ''')
|
|
}
|
|
|
|
// Safely inline JSON in a <script> tag (blocks </script> + --> breakouts)
|
|
function jsonScript (value) {
|
|
return JSON.stringify(value).replace(/</g, '\\u003c').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 `<span class="bdg" style="background:${m.color}22;color:${m.color}">${esc(m.label)}</span>`
|
|
}
|
|
|
|
// ── 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 `<div class="sec${mod}">${esc(title)} <span class="cnt">${items.length}</span></div>${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 `<div class="${cls}"${click}${st}>${inner}</div>`
|
|
}
|
|
|
|
function panel (title, bodyHtml, { extra = '' } = {}) {
|
|
return `<div class="panel"><h3>${esc(title)}${extra ? ` <span class="rt">${extra}</span>` : ''}</h3>${bodyHtml}</div>`
|
|
}
|
|
|
|
function emptyState (emoji, message) {
|
|
return `<div class="empty"><div class="em">${esc(emoji)}</div><p>${message}</p></div>`
|
|
}
|
|
|
|
function placeholder (emoji, title, body) {
|
|
return `<div class="placeholder"><div class="em">${esc(emoji)}</div><h2>${esc(title)}</h2><p>${body}</p></div>`
|
|
}
|
|
|
|
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 `<button class="${cls}"${id ? ` id="${id}"` : ''}${onclick ? ` onclick="${onclick}"` : ''}${disabled ? ' disabled' : ''}${style ? ` style="${style}"` : ''}>${label}</button>`
|
|
}
|
|
|
|
// ── Stat row (inside header) ──────────────────────────────────────────────
|
|
// items: [{ value, label, onclick? }]
|
|
function statRow (items) {
|
|
return `<div class="sts">${items.map(it => {
|
|
const click = it.onclick ? ` onclick="${it.onclick}"` : ''
|
|
const id = it.id ? ` id="${it.id}"` : ''
|
|
return `<div class="st"${click}><b${id}>${esc(it.value)}</b><small>${esc(it.label)}</small></div>`
|
|
}).join('')}</div>`
|
|
}
|
|
|
|
// ── Tab bar ───────────────────────────────────────────────────────────────
|
|
// tabs: [{ id, label, icon, active? }]
|
|
function tabBar (tabs) {
|
|
return `<div class="tbar">${tabs.map(t => {
|
|
const cls = 'tab' + (t.active ? ' on' : '')
|
|
return `<button class="${cls}" data-v="${esc(t.id)}" onclick="go('#${esc(t.id)}')"><span class="ic">${esc(t.icon)}</span>${esc(t.label)}</button>`
|
|
}).join('')}</div>`
|
|
}
|
|
|
|
// ── 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,
|
|
}
|