feat : ajout d'un système de scanner bovin
This commit is contained in:
36
.idea/workspace.xml
generated
36
.idea/workspace.xml
generated
@@ -5,18 +5,10 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : système de blocage utilisateur">
|
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : système de blocage utilisateur">
|
||||||
<change afterPath="$PROJECT_DIR$/migrations/Version20260325142815.php" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/src/Security/UserChecker.php" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/src/State/ActiveUsersProvider.php" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/config/packages/security.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/config/packages/security.yaml" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/frontend/pages/admin/user/[[id]].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/user/[[id]].vue" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/frontend/i18n/locales/fr.json" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/i18n/locales/fr.json" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/frontend/pages/admin/user/list.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/user/list.vue" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/frontend/layouts/default.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/layouts/default.vue" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/frontend/services/dto/user-data.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/services/dto/user-data.ts" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/frontend/stores/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/stores/auth.ts" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/Entity/User.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/User.php" afterDir="false" />
|
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -48,7 +40,7 @@
|
|||||||
<component name="Git.Settings">
|
<component name="Git.Settings">
|
||||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||||
<map>
|
<map>
|
||||||
<entry key="$PROJECT_DIR$" value="fix/FER-11-corriger-le-probleme-de-bearer-token" />
|
<entry key="$PROJECT_DIR$" value="feature/FER-12-ajouter-un-blocage-des-utilisateurs" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
@@ -239,7 +231,7 @@
|
|||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"git-widget-placeholder": "feature/FER-12-ajouter-un-blocage-des-utilisateurs",
|
"git-widget-placeholder": "feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes",
|
||||||
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
|
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.detected.package.tslint": "true",
|
"node.js.detected.package.tslint": "true",
|
||||||
@@ -333,15 +325,7 @@
|
|||||||
<workItem from="1773766075191" duration="6202000" />
|
<workItem from="1773766075191" duration="6202000" />
|
||||||
<workItem from="1773824491213" duration="24805000" />
|
<workItem from="1773824491213" duration="24805000" />
|
||||||
<workItem from="1774275549972" duration="51000" />
|
<workItem from="1774275549972" duration="51000" />
|
||||||
<workItem from="1774276665015" duration="16178000" />
|
<workItem from="1774276665015" duration="27805000" />
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00032" summary="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1769100048933</created>
|
|
||||||
<option name="number" value="00032" />
|
|
||||||
<option name="presentableId" value="LOCAL-00032" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1769100048933</updated>
|
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00033" summary="feat : ajout de la debug bar en mod dev">
|
<task id="LOCAL-00033" summary="feat : ajout de la debug bar en mod dev">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -727,7 +711,15 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1774448105945</updated>
|
<updated>1774448105945</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="81" />
|
<task id="LOCAL-00081" summary="feat : système de blocage utilisateur">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1774450388149</created>
|
||||||
|
<option name="number" value="00081" />
|
||||||
|
<option name="presentableId" value="LOCAL-00081" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1774450388149</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="82" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
|||||||
113
frontend/composables/useBarcodeScanner.ts
Normal file
113
frontend/composables/useBarcodeScanner.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
BarcodeDetector: new (options?: { formats: string[] }) => {
|
||||||
|
detect(source: HTMLVideoElement | ImageBitmapSource): Promise<{ rawValue: string }[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const BarcodeDetector: Window['BarcodeDetector'] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBarcodeScanner(onDetected: (code: string) => void) {
|
||||||
|
const isSupported = ref('BarcodeDetector' in globalThis)
|
||||||
|
const isScanning = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
let detector: InstanceType<Window['BarcodeDetector']> | null = null
|
||||||
|
let stream: MediaStream | null = null
|
||||||
|
let animationFrameId: number | null = null
|
||||||
|
let lastDetectedCode = ''
|
||||||
|
let lastDetectedTime = 0
|
||||||
|
|
||||||
|
const COOLDOWN_MS = 2000
|
||||||
|
|
||||||
|
async function start(videoElement: HTMLVideoElement) {
|
||||||
|
if (!isSupported.value) {
|
||||||
|
error.value = 'BarcodeDetector non supporté. Utilisez Chrome sur Android.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
detector = new BarcodeDetector({ formats: ['code_39', 'code_128'] })
|
||||||
|
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment',
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
videoElement.srcObject = stream
|
||||||
|
await videoElement.play()
|
||||||
|
isScanning.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
scanLoop(videoElement)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur lors du démarrage de la caméra'
|
||||||
|
isScanning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanLoop(videoElement: HTMLVideoElement) {
|
||||||
|
if (!isScanning.value || !detector) return
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(async () => {
|
||||||
|
try {
|
||||||
|
if (videoElement.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
|
||||||
|
const barcodes = await detector!.detect(videoElement)
|
||||||
|
|
||||||
|
if (barcodes.length > 0) {
|
||||||
|
const code = barcodes[0].rawValue.slice(4)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (code !== lastDetectedCode || now - lastDetectedTime > COOLDOWN_MS) {
|
||||||
|
lastDetectedCode = code
|
||||||
|
lastDetectedTime = now
|
||||||
|
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDetected(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Detection error on single frame, continue
|
||||||
|
}
|
||||||
|
|
||||||
|
scanLoop(videoElement)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
isScanning.value = false
|
||||||
|
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameId)
|
||||||
|
animationFrameId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
detector = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
isScanning,
|
||||||
|
error,
|
||||||
|
start,
|
||||||
|
stop
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,9 @@
|
|||||||
"create": "Impossible de créer le type bovin.",
|
"create": "Impossible de créer le type bovin.",
|
||||||
"update": "Impossible de mettre à jour le type bovin."
|
"update": "Impossible de mettre à jour le type bovin."
|
||||||
},
|
},
|
||||||
|
"bovine": {
|
||||||
|
"create": "Impossible d'enregistrer le bovin."
|
||||||
|
},
|
||||||
"carrier": {
|
"carrier": {
|
||||||
"list": "Impossible de récupérer la liste des transporteurs.",
|
"list": "Impossible de récupérer la liste des transporteurs.",
|
||||||
"fetch": "Impossible de récupérer les données du transporteur",
|
"fetch": "Impossible de récupérer les données du transporteur",
|
||||||
@@ -146,6 +149,9 @@
|
|||||||
"update": "Type bovin mis à jour avec succès.",
|
"update": "Type bovin mis à jour avec succès.",
|
||||||
"create": "Type bovin créé avec succès."
|
"create": "Type bovin créé avec succès."
|
||||||
},
|
},
|
||||||
|
"bovine": {
|
||||||
|
"create": "Bovin enregistré avec succès."
|
||||||
|
},
|
||||||
"weight": {
|
"weight": {
|
||||||
"update": "Pesée mis à jour"
|
"update": "Pesée mis à jour"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,22 @@
|
|||||||
Bovins
|
Bovins
|
||||||
</a>
|
</a>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/scan"
|
||||||
|
custom
|
||||||
|
v-slot="{ href, navigate }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="href"
|
||||||
|
@click="navigate"
|
||||||
|
:class="route.path.startsWith('/scan')
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-65 hover:opacity-100 transition'"
|
||||||
|
>
|
||||||
|
Scanner
|
||||||
|
</a>
|
||||||
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
|
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
|
||||||
@@ -218,6 +234,9 @@
|
|||||||
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
|
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
|
||||||
Bovins
|
Bovins
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/scan" @click="closeMenu">
|
||||||
|
Scanner
|
||||||
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -231,7 +250,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</transition>
|
</transition>
|
||||||
</header>
|
</header>
|
||||||
<main class="mx-auto w-full max-w-[1280px] mt-16">
|
<main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
|
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
|
||||||
|
|||||||
229
frontend/pages/scan.vue
Normal file
229
frontend/pages/scan.vue
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<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">
|
||||||
|
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>
|
||||||
29
frontend/services/bovine.ts
Normal file
29
frontend/services/bovine.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import type { BovineData, BovinePayload } from '~/services/dto/bovine-data'
|
||||||
|
|
||||||
|
export async function createBovine(payload: BovinePayload) {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post<BovineData>('bovines', payload, {
|
||||||
|
headers: { 'Content-Type': 'application/ld+json' },
|
||||||
|
toastErrorKey: 'errors.bovine.create',
|
||||||
|
toastSuccessKey: 'success.bovine.create'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBovines(nationalNumbers: string[]): Promise<{ created: BovineData[]; errors: string[] }> {
|
||||||
|
const created: BovineData[] = []
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
for (const nationalNumber of nationalNumbers) {
|
||||||
|
try {
|
||||||
|
const bovine = await createBovine({ nationalNumber })
|
||||||
|
if (bovine) {
|
||||||
|
created.push(bovine)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errors.push(nationalNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, errors }
|
||||||
|
}
|
||||||
14
frontend/services/dto/bovine-data.ts
Normal file
14
frontend/services/dto/bovine-data.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export interface BovineData {
|
||||||
|
id: number
|
||||||
|
nationalNumber: string
|
||||||
|
receivedWeight: number | null
|
||||||
|
arrivalDate: string | null
|
||||||
|
buildingCase: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BovinePayload = {
|
||||||
|
nationalNumber?: string
|
||||||
|
receivedWeight?: number | null
|
||||||
|
arrivalDate?: string | null
|
||||||
|
buildingCase?: string | null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user