All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
7.8 KiB
Vue
232 lines
7.8 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-4 px-4">
|
|
<h1 class="text-2xl text-primary-500 font-bold uppercase">Scanner des bovins</h1>
|
|
|
|
<!-- Message si non supporté -->
|
|
<div v-if="!scanner.isSupported.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700 flex flex-col w-full">
|
|
<p class="font-bold">Scanner non disponible</p>
|
|
<p class="text-sm mt-1">BarcodeDetector n'est pas supportée par ce navigateur. Utilisez Chrome sur Android.</p>
|
|
</div>
|
|
|
|
<!-- Erreur caméra -->
|
|
<div v-if="scanner.error.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700">
|
|
<p>{{ scanner.error.value }}</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-4">
|
|
<UiSelect
|
|
id="scan-building"
|
|
v-model="selectedBuildingId"
|
|
label="Bâtiment"
|
|
:options="buildingOptions"
|
|
wrapper-class="w-full max-w-[280px]"
|
|
required
|
|
/>
|
|
|
|
<UiSelect
|
|
id="scan-case"
|
|
v-model="selectedCaseId"
|
|
label="Case"
|
|
:options="caseOptions"
|
|
:disabled="!selectedBuildingId"
|
|
wrapper-class="w-full max-w-[280px]"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<!-- Zone caméra pleine hauteur -->
|
|
<div v-if="showScanner" class="fixed inset-0 z-50 flex flex-col bg-black overflow-hidden">
|
|
<!-- Header scanner -->
|
|
<div class="flex items-center justify-between px-4 py-3 bg-black/90">
|
|
<div class="text-white text-sm font-semibold">
|
|
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
|
|
</div>
|
|
<UiButton
|
|
type="button"
|
|
class="text-md font-bold uppercase bg-red-500 text-white h-[40px] px-4"
|
|
@click="stopScanning"
|
|
>
|
|
Arrêter
|
|
</UiButton>
|
|
</div>
|
|
<div class="flex-1 relative">
|
|
<video
|
|
ref="videoRef"
|
|
class="w-full h-full object-cover"
|
|
playsinline
|
|
muted
|
|
/>
|
|
<!-- Overlay zone de scan -->
|
|
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div class="w-[90%] h-48 border-2 border-white/70 rounded-lg" />
|
|
</div>
|
|
<!-- Dernier scan -->
|
|
<div v-if="lastScanned" class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-green-600/80 text-white px-4 py-2 rounded-full text-sm font-semibold">
|
|
{{ lastScanned }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bouton démarrer -->
|
|
<div v-if="!showScanner" class="flex gap-3">
|
|
<UiButton
|
|
type="button"
|
|
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full max-w-[272px]"
|
|
:disabled="!scanner.isSupported.value"
|
|
@click="startScanning"
|
|
>
|
|
Démarrer le scanner
|
|
</UiButton>
|
|
</div>
|
|
|
|
<!-- Liste des numéros scannés -->
|
|
<div class="flex flex-col gap-3">
|
|
<p class="text-sm text-slate-500 font-semibold">
|
|
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
|
|
</p>
|
|
|
|
<div
|
|
v-for="(entry, index) in entries"
|
|
:key="index"
|
|
class="flex items-center gap-2"
|
|
>
|
|
<UiTextInput
|
|
:id="`scan-entry-${index}`"
|
|
:ref="(el: any) => setInputRef(el?.$el?.querySelector('input') ?? el, index)"
|
|
:model-value="entries[index]"
|
|
placeholder="Numéro national"
|
|
wrapper-class="flex-1 max-w-md"
|
|
@update:model-value="(val: string) => entries[index] = val ?? ''"
|
|
/>
|
|
<button
|
|
v-if="entries.length > 1"
|
|
type="button"
|
|
class="text-red-400 hover:text-red-600"
|
|
@click="removeEntry(index)"
|
|
>
|
|
<Icon name="mdi:close-circle" size="24" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-3 mt-4">
|
|
<UiButton
|
|
type="button"
|
|
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full"
|
|
:disabled="scannedCount === 0 || isSubmitting || !selectedCaseId"
|
|
:loading="isSubmitting"
|
|
@click="submit"
|
|
>
|
|
Valider ({{ scannedCount }})
|
|
</UiButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
useHead({ title: 'Scanner' })
|
|
|
|
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
|
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
|
|
import { createBovine } from '~/services/bovine'
|
|
import { getBuildingList } from '~/services/building'
|
|
import type { BuildingData } from '~/services/dto/building-data'
|
|
|
|
const videoRef = ref<HTMLVideoElement>()
|
|
const entries = ref<string[]>([''])
|
|
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
|
const isSubmitting = ref(false)
|
|
const lastScanned = ref('')
|
|
const showScanner = ref(false)
|
|
|
|
const buildings = ref<BuildingData[]>([])
|
|
const selectedBuildingId = ref<string | number | null>(null)
|
|
const selectedCaseId = ref<string | number | null>(null)
|
|
|
|
const buildingOptions = computed(() =>
|
|
buildings.value.map(b => ({ value: b.id, label: b.label }))
|
|
)
|
|
|
|
const caseOptions = computed(() => {
|
|
const building = buildings.value.find(b => b.id === Number(selectedBuildingId.value))
|
|
if (!building?.buildingCases) return []
|
|
return [...building.buildingCases]
|
|
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
|
|
.map(c => ({
|
|
value: c.id,
|
|
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
|
|
}))
|
|
})
|
|
|
|
watch(selectedBuildingId, () => {
|
|
selectedCaseId.value = null
|
|
})
|
|
|
|
onMounted(async () => {
|
|
buildings.value = await getBuildingList()
|
|
})
|
|
|
|
const scannedCount = computed(() => entries.value.filter(e => e.trim() !== '').length)
|
|
|
|
function setInputRef(el: HTMLInputElement | null, index: number) {
|
|
inputRefs.value[index] = el
|
|
}
|
|
|
|
const scanner = useBarcodeScanner((code: string) => {
|
|
if (entries.value.some(e => e.trim() === code)) return
|
|
|
|
const emptyIndex = entries.value.findIndex(e => e.trim() === '')
|
|
if (emptyIndex !== -1) {
|
|
entries.value[emptyIndex] = code
|
|
} else {
|
|
entries.value.push(code)
|
|
}
|
|
|
|
lastScanned.value = code
|
|
entries.value.push('')
|
|
})
|
|
|
|
function startScanning() {
|
|
showScanner.value = true
|
|
nextTick(() => {
|
|
if (videoRef.value) {
|
|
scanner.start(videoRef.value)
|
|
}
|
|
})
|
|
}
|
|
|
|
function stopScanning() {
|
|
scanner.stop()
|
|
showScanner.value = false
|
|
lastScanned.value = ''
|
|
}
|
|
|
|
function removeEntry(index: number) {
|
|
entries.value.splice(index, 1)
|
|
inputRefs.value.splice(index, 1)
|
|
}
|
|
|
|
async function submit() {
|
|
const numbers = entries.value.filter(e => e.trim() !== '').map(e => e.trim())
|
|
if (numbers.length === 0 || !selectedCaseId.value) return
|
|
|
|
const caseIri = `/api/building_cases/${selectedCaseId.value}`
|
|
isSubmitting.value = true
|
|
|
|
try {
|
|
let successCount = 0
|
|
for (const nationalNumber of numbers) {
|
|
const result = await createBovine({ nationalNumber, buildingCase: caseIri })
|
|
if (result) successCount++
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
clearAll()
|
|
}
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
</script>
|