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>
536 lines
20 KiB
Vue
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>
|