gigafibre-fsm/services/targo-hub/lib/ui/components.js
louispaulb 169426a6d8 refactor(targo-hub): extract ui/ kit, migrate tech-mobile to it
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.
2026-04-22 22:47:19 -04:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
// 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,
}