gigafibre-fsm/apps/field/src/pages/ScanPage.vue
louispaulb 30a867a326 fix(tech): restore Gemini-native scanner + port equipment UX into ops
The ops tech module at /ops/#/j/* had drifted from the field app in two ways:

1. Scanner — a prior "restoration" re-added html5-qrcode, but the
   design has always been native <input capture="environment"> → Gemini
   2.5 Flash via targo-hub /vision/barcodes (up to 3 codes) and
   /vision/equipment (structured labels, up to 5). Revert useScanner.js
   + ScanPage.vue + TechScanPage.vue to commit e50ea88 and drop
   html5-qrcode from both package.json + lockfiles. No JS barcode
   library, no camera stream, no polyfills.

2. Equipment UX — TechJobDetailPage.vue was a 186-line stub missing the
   Ajouter bottom-sheet (Scanner / Rechercher / Créer), the debounced
   SN-then-MAC search, the 5-field create dialog, Type + Priority
   selects on the info card, and the location-detail contact expansion.
   Port the full UX from apps/field/src/pages/JobDetailPage.vue (526
   lines) into the ops module (458 lines after consolidation).

Rebuilt and deployed both apps. Remote smoke test confirms 0 bundles
reference html5-qrcode and the new TechJobDetailPage.1075b3b8.js chunk
(16.7 KB vs ~5 KB stub) ships the equipment bottom-sheet strings.

Docs:

- docs/features/tech-mobile.md — new. Documents all three delivery
  surfaces (legacy SSR /t/{jwt}, transitional apps/field/, unified
  /ops/#/j/*), Gemini-native scanner pipeline, equipment UX, magic-link
  JWT, cutover plan. Replaces an earlier stub that incorrectly
  referenced html5-qrcode.
- docs/features/dispatch.md — new. Dispatch board, scheduling, tags,
  travel-time optimization, magic-link SMS, SSE updates.
- docs/features/customer-portal.md — new. Plan A passwordless magic-link
  at portal.gigafibre.ca, Stripe self-service, file inventory.
- docs/architecture/module-interactions.md — new. One-page call graph
  with sequence diagrams for the hot paths.
- docs/README.md — expanded module index (§2) now lists every deployed
  surface with URL + primary doc + primary code locations (was missing
  dispatch, tickets, équipe, rapports, telephony, network, agent-flows,
  OCR, every customer-portal page). New cross-module edge map in §4.
- docs/features/README.md + docs/architecture/README.md — cross-link
  all new docs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:56:38 -04:00

536 lines
20 KiB
Vue

<template>
<q-page padding class="scan-page">
<!-- Job context banner -->
<q-card v-if="jobContext" flat bordered class="q-mb-md bg-blue-1">
<q-card-section class="q-py-sm row items-center no-wrap">
<q-icon name="work" color="primary" class="q-mr-sm" />
<div class="col">
<div class="text-subtitle2">{{ jobContext.customer_name || jobContext.customer }}</div>
<div class="text-caption text-grey" v-if="jobContext.location_name">
<q-icon name="place" size="xs" /> {{ jobContext.location_name }}
</div>
</div>
<q-btn flat dense size="sm" icon="close" @click="jobContext = null" />
</q-card-section>
<q-card-section class="q-pt-none q-pb-sm text-caption text-blue-grey">
Les équipements scannés seront automatiquement liés à ce client et cette adresse.
</q-card-section>
</q-card>
<!-- Camera capture button -->
<div class="text-center">
<q-btn
color="primary" icon="photo_camera" label="Scanner"
size="lg" rounded unelevated
@click="takePhoto"
:loading="scanner.scanning.value"
class="q-px-xl"
/>
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
</div>
<!-- Pending scan indicator (signal faible) -->
<div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm">
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable @click="offline.syncVisionQueue()">
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
</q-chip>
</div>
<!-- Last captured photo (thumbnail) -->
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
<div v-if="scanner.scanning.value" class="preview-overlay">
<q-spinner-dots size="32px" color="white" />
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
</div>
</div>
<!-- Error / status -->
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
{{ scanner.error.value }}
</div>
<!-- Manual entry -->
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC" outlined dense class="q-mt-md"
@keyup.enter="addManual">
<template v-slot:append>
<q-btn flat dense icon="add" @click="addManual" :disable="!manualCode.trim()" />
</template>
</q-input>
<!-- Scanned barcodes -->
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
<div class="text-subtitle2 q-mb-xs">Codes détectés ({{ scanner.barcodes.value.length }}/3)</div>
<q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm">
<q-card-section class="q-py-sm row items-center no-wrap">
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
<div class="col">
<div class="text-subtitle2 mono">{{ bc.value }}</div>
</div>
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
</q-card-section>
<!-- Lookup result -->
<q-card-section v-if="lookupResults[bc.value]" class="q-pt-none">
<div v-if="lookupResults[bc.value].found">
<q-badge color="green" label="Trouvé" class="q-mb-xs" />
<div class="text-caption">
{{ lookupResults[bc.value].equipment.equipment_type }} —
{{ lookupResults[bc.value].equipment.brand }} {{ lookupResults[bc.value].equipment.model }}
</div>
<div class="text-caption">
Client: {{ lookupResults[bc.value].equipment.customer_name || lookupResults[bc.value].equipment.customer || 'Aucun' }}
</div>
<div v-if="!lookupResults[bc.value].equipment.service_location && !jobContext" class="q-mt-xs">
<q-btn flat dense size="sm" color="orange" label="Lier à un service" icon="link"
@click="openLinkDialog(bc.value, lookupResults[bc.value].equipment)" />
</div>
<div v-else class="text-caption text-green q-mt-xs">
<q-icon name="check_circle" size="xs" class="q-mr-xs" />
{{ lookupResults[bc.value].equipment.service_location }}
</div>
<q-btn flat dense size="sm" label="Détails" icon="open_in_new" class="q-mt-xs"
@click="$router.push({ name: 'device', params: { serial: lookupResults[bc.value].equipment.serial_number || bc.value } })" />
</div>
<div v-else>
<q-badge color="orange" label="Non trouvé" class="q-mb-xs" />
<q-btn flat dense size="sm" color="primary" label="Créer équipement" icon="add"
@click="openCreateDialog(bc.value)" />
</div>
</q-card-section>
</q-card>
</div>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div>
<!-- Link all to account (manual, when no job context) -->
<div v-if="scanner.barcodes.value.length > 0 && !jobContext && hasUnlinked" class="q-mt-sm">
<q-btn color="orange" icon="link" label="Lier les équipements à un service"
@click="openLinkDialogForAll" outline class="full-width" />
</div>
<!-- Full photo viewer -->
<q-dialog v-model="showFullPhoto" maximized>
<q-card class="bg-black column">
<q-card-section class="col-auto row items-center">
<div class="text-white text-subtitle2 col">Photo</div>
<q-btn flat round icon="close" color="white" v-close-popup />
</q-card-section>
<q-card-section class="col column items-center justify-center">
<img :src="fullPhotoUrl" style="max-width:100%; max-height:80vh; object-fit:contain" />
</q-card-section>
</q-card>
</q-dialog>
<!-- Create equipment dialog -->
<q-dialog v-model="createDialog">
<q-card style="min-width: 320px">
<q-card-section>
<div class="text-h6">Nouvel équipement</div>
</q-card-section>
<q-card-section>
<q-input v-model="newEquip.serial_number" label="Numéro de série" outlined dense readonly class="q-mb-sm" />
<q-select v-model="newEquip.equipment_type" :options="eqTypes" label="Type" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.brand" label="Marque" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.model" label="Modèle" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense class="q-mb-sm" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Créer" @click="createEquipment" :loading="creating" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Link device to service dialog -->
<q-dialog v-model="linkDialog">
<q-card style="min-width: 340px">
<q-card-section>
<div class="text-h6">Lier à un service</div>
<div class="text-caption text-grey mono">{{ linkTarget?.serial_number }}</div>
</q-card-section>
<q-card-section>
<q-input v-model="linkSearch" label="Rechercher client" outlined dense class="q-mb-sm"
@update:model-value="searchCustomers" debounce="400">
<template v-slot:append><q-icon name="search" /></template>
</q-input>
<q-list v-if="customerResults.length" bordered separator class="q-mb-sm" style="max-height: 150px; overflow-y: auto">
<q-item v-for="c in customerResults" :key="c.name" clickable @click="selectCustomer(c)">
<q-item-section>
<q-item-label>{{ c.customer_name || c.name }}</q-item-label>
<q-item-label caption>{{ c.name }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<div v-if="selectedCustomer">
<div class="text-subtitle2 q-mb-xs">{{ selectedCustomer.customer_name || selectedCustomer.name }}</div>
<div v-if="loadingLocations" class="text-center q-py-sm"><q-spinner size="sm" /></div>
<q-list v-else-if="serviceLocations.length" bordered separator>
<q-item v-for="loc in serviceLocations" :key="loc.name" clickable
:class="{ 'bg-blue-1': selectedLocation?.name === loc.name }"
@click="selectedLocation = loc">
<q-item-section>
<q-item-label>{{ loc.location_name || loc.name }}</q-item-label>
<q-item-label caption>{{ loc.address_line }} {{ loc.city }}</q-item-label>
</q-item-section>
<q-item-section side v-if="selectedLocation?.name === loc.name">
<q-icon name="check_circle" color="primary" />
</q-item-section>
</q-item>
</q-list>
<div v-else class="text-caption text-grey">Aucune adresse de service</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Lier" :disable="!selectedCustomer || !selectedLocation"
@click="linkDeviceToService" :loading="linkingSingle" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useScanner } from 'src/composables/useScanner'
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
import { useOfflineStore } from 'src/stores/offline'
import { Notify } from 'quasar'
const route = useRoute()
const offline = useOfflineStore()
const scanner = useScanner({
onNewCode: (code) => {
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
lookupDevice(code)
},
})
const cameraInput = ref(null)
const manualCode = ref('')
const lookingUp = ref(null)
const lookupResults = ref({})
const createDialog = ref(false)
const creating = ref(false)
// Photo viewer
const showFullPhoto = ref(false)
const fullPhotoUrl = ref('')
// Link dialog
const linkDialog = ref(false)
const linkTarget = ref(null)
const linkTargetBarcode = ref('')
const linkSearch = ref('')
const customerResults = ref([])
const selectedCustomer = ref(null)
const serviceLocations = ref([])
const selectedLocation = ref(null)
const loadingLocations = ref(false)
const linkingSingle = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
const jobContext = ref(route.query.job ? {
job: route.query.job,
customer: route.query.customer,
customer_name: route.query.customer_name,
location: route.query.location,
location_name: route.query.location_name,
} : null)
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
const hasUnlinked = computed(() =>
scanner.barcodes.value.some(bc => {
const r = lookupResults.value[bc.value]
return r?.found && !r.equipment.service_location
})
)
// --- Camera ---
function takePhoto () {
// Reset the input so same file triggers change
if (cameraInput.value) cameraInput.value.value = ''
cameraInput.value?.click()
}
async function onPhoto (e) {
const file = e.target.files?.[0]
if (!file) return
// Codes are delivered through the onNewCode callback registered on the
// scanner — fires both for sync scans and for queued scans that complete
// later when the signal comes back.
await scanner.processPhoto(file)
}
function viewPhoto (photo) {
fullPhotoUrl.value = photo.url
showFullPhoto.value = true
}
// --- Manual entry ---
function addManual () {
const code = manualCode.value.trim()
if (!code) return
if (scanner.barcodes.value.length >= 3) {
Notify.create({ type: 'warning', message: 'Maximum 3 codes' })
return
}
if (!scanner.barcodes.value.find(b => b.value === code)) {
scanner.barcodes.value.push({ value: code, region: 'manuel' })
lookupDevice(code)
}
manualCode.value = ''
}
// --- Device lookup ---
async function lookupDevice (serial) {
lookingUp.value = serial
try {
const results = await listDocs('Service Equipment', {
filters: { serial_number: serial },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
'service_location', 'status', 'mac_address'],
limit: 1,
})
if (results.length > 0) {
lookupResults.value[serial] = { found: true, equipment: results[0] }
return
}
const byBarcode = await listDocs('Service Equipment', {
filters: { barcode: serial },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
'service_location', 'status', 'mac_address'],
limit: 1,
})
if (byBarcode.length > 0) {
lookupResults.value[serial] = { found: true, equipment: byBarcode[0] }
return
}
const normalized = serial.replace(/[:\-\.]/g, '').toUpperCase()
if (normalized.length === 12 && /^[A-F0-9]+$/.test(normalized)) {
const byMac = await listDocs('Service Equipment', {
filters: { mac_address: ['like', `%${normalized.slice(-6)}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'customer', 'customer_name',
'service_location', 'status', 'mac_address'],
limit: 1,
})
if (byMac.length > 0) {
lookupResults.value[serial] = { found: true, equipment: byMac[0] }
return
}
}
lookupResults.value[serial] = { found: false }
} catch {
lookupResults.value[serial] = { found: false }
} finally {
lookingUp.value = null
}
// Auto-link to job context if device found but not yet linked
const result = lookupResults.value[serial]
if (result?.found && jobContext.value?.customer && !result.equipment.service_location) {
await autoLinkToJob(serial, result.equipment)
}
}
// --- Auto-link device to job context ---
async function autoLinkToJob (serial, equipment) {
if (!jobContext.value?.customer) return
const updates = { customer: jobContext.value.customer }
if (jobContext.value.location) updates.service_location = jobContext.value.location
try {
await updateDoc('Service Equipment', equipment.name, updates)
equipment.customer = jobContext.value.customer
equipment.customer_name = jobContext.value.customer_name
if (jobContext.value.location) equipment.service_location = jobContext.value.location
// Update lookupResults
if (lookupResults.value[serial]) {
lookupResults.value[serial].equipment = { ...equipment }
}
Notify.create({
type: 'positive',
message: 'Lié à ' + (jobContext.value.customer_name || jobContext.value.customer),
caption: jobContext.value.location_name || undefined,
icon: 'link',
})
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur liaison: ' + e.message })
}
}
// --- Create equipment ---
function openCreateDialog (serial) {
newEquip.value = { serial_number: serial, equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
createDialog.value = true
}
async function createEquipment () {
creating.value = true
const data = {
...newEquip.value,
status: 'Actif',
customer: jobContext.value?.customer || '',
service_location: jobContext.value?.location || '',
}
try {
if (offline.online) {
const doc = await createDoc('Service Equipment', data)
lookupResults.value[data.serial_number] = { found: true, equipment: doc }
Notify.create({ type: 'positive', message: 'Équipement créé' })
} else {
await offline.enqueue({ type: 'create', doctype: 'Service Equipment', data })
Notify.create({ type: 'info', message: 'Sauvegardé hors ligne' })
}
createDialog.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
creating.value = false
}
}
// --- Link dialog for unlinked devices (no job context) ---
function openLinkDialogForAll () {
// Find first unlinked device
for (const bc of scanner.barcodes.value) {
const r = lookupResults.value[bc.value]
if (r?.found && !r.equipment.service_location) {
openLinkDialog(bc.value, r.equipment)
return
}
}
}
// --- Link device to service ---
function openLinkDialog (barcode, equipment) {
linkTarget.value = equipment
linkTargetBarcode.value = barcode
linkSearch.value = ''
customerResults.value = []
selectedCustomer.value = null
serviceLocations.value = []
selectedLocation.value = null
if (equipment.customer) {
selectedCustomer.value = { name: equipment.customer, customer_name: equipment.customer_name }
loadServiceLocations(equipment.customer)
}
linkDialog.value = true
}
async function searchCustomers (text) {
if (!text || text.length < 2) { customerResults.value = []; return }
try {
customerResults.value = await listDocs('Customer', {
filters: { customer_name: ['like', `%${text}%`] },
fields: ['name', 'customer_name'],
limit: 10,
})
} catch { customerResults.value = [] }
}
async function selectCustomer (customer) {
selectedCustomer.value = customer
customerResults.value = []
linkSearch.value = ''
selectedLocation.value = null
await loadServiceLocations(customer.name)
}
async function loadServiceLocations (customerId) {
loadingLocations.value = true
try {
serviceLocations.value = await listDocs('Service Location', {
filters: { customer: customerId },
fields: ['name', 'location_name', 'address_line', 'city', 'connection_type'],
limit: 50,
})
} catch { serviceLocations.value = [] }
finally { loadingLocations.value = false }
}
async function linkDeviceToService () {
if (!linkTarget.value || !selectedCustomer.value || !selectedLocation.value) return
linkingSingle.value = true
try {
await updateDoc('Service Equipment', linkTarget.value.name, {
customer: selectedCustomer.value.name,
service_location: selectedLocation.value.name,
})
linkTarget.value.customer = selectedCustomer.value.name
linkTarget.value.customer_name = selectedCustomer.value.customer_name
linkTarget.value.service_location = selectedLocation.value.name
if (lookupResults.value[linkTargetBarcode.value]) {
lookupResults.value[linkTargetBarcode.value].equipment = { ...linkTarget.value }
}
Notify.create({ type: 'positive', message: 'Lié à ' + (selectedLocation.value.location_name || selectedLocation.value.name) })
linkDialog.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
linkingSingle.value = false
}
}
</script>
<style lang="scss" scoped>
.scan-page {
padding-bottom: 16px !important;
}
.photo-preview {
position: relative;
text-align: center;
}
.preview-img {
max-width: 100%;
max-height: 250px;
border-radius: 12px;
cursor: pointer;
}
.preview-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.5);
border-radius: 12px;
}
.photo-thumb {
position: relative;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
</style>