feat(tech-mobile): SPA redesign with tabs, detail view, notes, photos, field-scan

Rewrote msg.gigafibre.ca (tech magic-link page) from a today-only flat list
into a proper 4-tab SPA:
- Aujourd'hui: In Progress / En retard / Aujourd'hui / Sans date / À venir
- Calendrier: placeholder (phase 4)
- Historique: searchable + filter chips (Tous/Terminés/Manqués/Annulés)
- Profil: tech info, support line, refresh

Job detail view (hash-routed, #job/DJ-xxx):
- Customer + tap-to-call/navigate block
- Editable notes (textarea → PUT /api/resource/Dispatch Job)
- Photo upload (base64 → File doctype, is_private, proxied back via /photo-serve)
- Equipment section (inherited from overlay)
- Sticky action bar (Démarrer / Terminer)

Equipment overlay extended with per-field Gemini Vision scanners. Each
input (SN, MAC, GPON SN, Wi-Fi SSID, Wi-Fi PWD, model) has a 📷 that opens
a capture modal; Gemini is prompted to find THAT field specifically and
returns value+confidence. Tech confirms or retries before the value fills in.

Root cause of the "tech can't see his job" bug: page filtered
scheduled_date=today, so jobs on any other day were invisible even though
the token was tech-scoped. Now fetches a ±60d window and groups client-side.

vision.js: new extractField(base64, field, ctx) helper + handleFieldScan
route (used by new /t/:token/field-scan endpoint).

Also fixes discovered along the way:
- Frappe v16 blocks fetched/linked fields (customer_name, service_location_name)
  and phantom fields (scheduled_time — real one is start_time). Query now
  uses only own fields; names resolved in two batch follow-up queries.
- "Today" is Montreal-local, not UTC. Prevents evening jobs being mislabeled
  as "hier" when UTC has already rolled to the next day.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-22 22:19:00 -04:00
parent 3db1dbae06
commit 1d23aa7814
2 changed files with 981 additions and 265 deletions

File diff suppressed because it is too large Load Diff

View File

@ -167,4 +167,86 @@ async function handleInvoice (req, res) {
}
}
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, handleInvoice }
// ─── Field-targeted extraction (for tech mobile form auto-fill) ─────────
// Instead of "read everything on the label", this pulls ONE specific value.
// Used when a tech has selected e.g. "Wi-Fi password" and wants Gemini to
// find only that field on the sticker. Returns {value, confidence}.
const FIELD_CONFIG = {
serial_number: {
desc: 'the device SERIAL NUMBER (labeled S/N, SN, Serial, N/S). Usually 8-20 alphanumeric chars, frequently printed under a Code128 barcode.',
clean: v => v.replace(/\s+/g, '').toUpperCase(),
},
mac_address: {
desc: 'the MAC ADDRESS (12 hexadecimal chars, may be separated by colons, dashes or nothing). Labeled MAC, WAN MAC, LAN MAC, Ethernet, Wi-Fi MAC.',
clean: v => v.replace(/[^0-9A-F]/gi, '').toUpperCase(),
},
gpon_sn: {
desc: 'the GPON SN — a 4-letter manufacturer code followed by 8 hex characters (e.g. HWTC12345678, ZTEG87654321, CIGG1A2B3C4D). Labeled GPON SN, GPON-SN, ONU SN.',
clean: v => v.replace(/\s+/g, '').toUpperCase(),
},
model: {
desc: 'the MODEL number/name (labeled M/N, Model, P/N, Product, Type). Usually short, e.g. "HG8245H", "TL-WR841N", "HS8145V".',
clean: v => v.trim(),
},
wifi_ssid: {
desc: 'the Wi-Fi NETWORK NAME (SSID). Labeled SSID, Wi-Fi name, WLAN SSID, Nom Wi-Fi, Nom du réseau.',
clean: v => v.trim(),
},
wifi_password: {
desc: 'the Wi-Fi PASSWORD / KEY. Labeled WPA, WPA2, WPA Key, Wi-Fi Password, Wireless Password, Clé Wi-Fi, Mot de passe Wi-Fi, Password, Passphrase. Usually 8-20 chars, mixed case with numbers and sometimes symbols.',
clean: v => v.trim(),
},
imei: {
desc: 'the IMEI (15 digits, exactly). Labeled IMEI.',
clean: v => v.replace(/\D/g, ''),
},
generic: {
desc: 'the requested value (see context hint below)',
clean: v => v.trim(),
},
}
const FIELD_SCHEMA = {
type: 'object',
properties: {
value: { type: 'string', nullable: true },
confidence: { type: 'number' },
},
required: ['value', 'confidence'],
}
async function extractField (base64Image, field, context = {}) {
const config = FIELD_CONFIG[field] || FIELD_CONFIG.generic
const eq = context.equipment_type ? `Equipment type hint: ${context.equipment_type}.` : ''
const brand = context.brand ? `Brand hint: ${context.brand}.` : ''
const model = context.model ? `Model hint: ${context.model}.` : ''
const custom = (field === 'generic' && context.hint) ? `Look for: ${context.hint}.` : ''
const prompt = `You are reading an ISP equipment label (ONT, router, modem). Extract ${config.desc}
${eq} ${brand} ${model} ${custom}
Return ONLY JSON matching the schema: {"value": "<the raw extracted text without its label prefix>", "confidence": <0.0-1.0>}.
If you cannot find it with confidence above 0.5, return {"value": null, "confidence": 0.0}.
Do NOT invent data. Prefer returning null over guessing.`
const parsed = await geminiVision(base64Image, prompt, FIELD_SCHEMA)
if (!parsed || !parsed.value) return { value: null, confidence: 0 }
const cleaned = config.clean(parsed.value)
if (!cleaned) return { value: null, confidence: 0 }
return { value: cleaned, confidence: Math.max(0, Math.min(1, Number(parsed.confidence) || 0.5)) }
}
async function handleFieldScan (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'field-scan')
if (check.error) return json(res, check.status, { error: check.error })
try {
const out = await extractField(check.base64, body.field || 'generic', {
hint: body.hint, equipment_type: body.equipment_type, brand: body.brand, model: body.model,
})
return json(res, 200, { ok: true, ...out })
} catch (e) {
log('Vision field-scan error:', e.message)
return json(res, 500, { error: 'Vision field extraction failed: ' + e.message })
}
}
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, handleInvoice, extractField, handleFieldScan }