Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions
6eb2ee2578 chore: bump version to v0.0.81
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m32s
2026-03-30 13:47:53 +00:00
34c1d162d8 [#FER-15] Fix droit de suppression réception/expédition utilisateur (!43)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #43
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 13:47:46 +00:00
gitea-actions
bbd05cea3e chore: bump version to v0.0.80
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m19s
2026-03-26 16:51:33 +00:00
7f78454553 [#FER-13] Faire des recherches sur le scanner des bêtes (!42)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #42
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-26 16:51:27 +00:00
15 changed files with 493 additions and 64 deletions

113
.idea/workspace.xml generated
View File

@@ -4,19 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<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" />
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
<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$/frontend/pages/admin/user/[[id]].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/user/[[id]].vue" 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/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" />
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -48,7 +41,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="fix/FER-11-corriger-le-probleme-de-bearer-token" />
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -239,7 +232,7 @@
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "feature/FER-12-ajouter-un-blocage-des-utilisateurs",
"git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
@@ -333,47 +326,7 @@
<workItem from="1773766075191" duration="6202000" />
<workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="16178000" />
</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 id="LOCAL-00033" summary="feat : ajout de la debug bar en mod dev">
<option name="closed" value="true" />
<created>1769177611987</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1769177611987</updated>
</task>
<task id="LOCAL-00034" summary="feat : ajout du bundle Malio ednotif pour l'utilisation des WS">
<option name="closed" value="true" />
<created>1769184861047</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1769184861047</updated>
</task>
<task id="LOCAL-00035" summary="fix : modification de la conf du bundle ednotif">
<option name="closed" value="true" />
<created>1769434793487</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1769434793487</updated>
</task>
<task id="LOCAL-00036" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
<created>1769435038236</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1769435038236</updated>
<workItem from="1774276665015" duration="33750000" />
</task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" />
@@ -727,7 +680,47 @@
<option name="project" value="LOCAL" />
<updated>1774448105945</updated>
</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>
<task id="LOCAL-00082" summary="feat : ajout d'un système de scanner bovin">
<option name="closed" value="true" />
<created>1774543296474</created>
<option name="number" value="00082" />
<option name="presentableId" value="LOCAL-00082" />
<option name="project" value="LOCAL" />
<updated>1774543296474</updated>
</task>
<task id="LOCAL-00083" summary="feat : mise à jour du CLAUDE.md">
<option name="closed" value="true" />
<created>1774543626516</created>
<option name="number" value="00083" />
<option name="presentableId" value="LOCAL-00083" />
<option name="project" value="LOCAL" />
<updated>1774543626516</updated>
</task>
<task id="LOCAL-00084" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1774543766582</created>
<option name="number" value="00084" />
<option name="presentableId" value="LOCAL-00084" />
<option name="project" value="LOCAL" />
<updated>1774543766582</updated>
</task>
<task id="LOCAL-00085" summary="feat : la page de scanner est accessible que pour les admins">
<option name="closed" value="true" />
<created>1774543840891</created>
<option name="number" value="00085" />
<option name="presentableId" value="LOCAL-00085" />
<option name="project" value="LOCAL" />
<updated>1774543840891</updated>
</task>
<option name="localTasksCounter" value="86" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -777,11 +770,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="fix: corrections diverses" />
<MESSAGE value="fix : corrections diverses" />
<MESSAGE value="fix : corrections frontend" />
<MESSAGE value="feat : affichage et modification expédition et modification bouton valider" />
<MESSAGE value="fix : erreur customer adress et bouton valider oublie" />
<MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" />
@@ -802,7 +790,12 @@
<MESSAGE value="fix : bouton de mise en attente" />
<MESSAGE value="fix : problème de bearer token" />
<MESSAGE value="feat : système de blocage utilisateur" />
<option name="LAST_COMMIT_MESSAGE" value="feat : système de blocage utilisateur" />
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>

View File

@@ -61,6 +61,8 @@ Ajouter dans le fichier .env du frontend
* [#353] modification front admin utilisateur
* [#FER-11] Corriger le problème de bearer token
* [#FER-12] Ajouter un blocage des utilisateurs
* [#FER-13] Faire des recherches sur le scanner des bêtes
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
### Changed

View File

@@ -142,6 +142,15 @@ frontend/
- `BuildingCase` a `bovines` (OneToMany).
- Rapport PDF cases : `GET /building_cases/{id}/weights-report` → template Twig, projection depuis `arrivalDate`, gain journalier fixe `1.3 kg/jour`.
### Scanner boucles auriculaires
- Page dédiée `/scan` : scan de codes-barres Code 39/128 (boucles auriculaires bovines) depuis un téléphone Android via Chrome.
- Utilise l'API native `BarcodeDetector` (Shape Detection API, Chrome Android 83+) — pas de lib JS, décodage hardware quasi-instantané.
- **Non supporté sur iOS** (tous les navigateurs iOS utilisent WebKit, qui n'implémente pas `BarcodeDetector`).
- Les 4 premiers caractères du code-barres sont retirés avant enregistrement (`rawValue.slice(4)`).
- Composable `useBarcodeScanner` : caméra arrière, anti-doublon 2s, vibration au scan.
- Le bovin est créé via `POST /bovines` avec `Content-Type: application/ld+json` (nécessaire pour la résolution d'IRI de `buildingCase`).
- Sélection bâtiment → case (filtrées dynamiquement) avant de scanner.
### Données de référence
- `ReceptionType`, `MerchandiseType`, `PelletType`, `Building`, `Supplier` (avec `Address` via join table, `createdBy` → User), `Customer` (avec `Address` via join table, `createdBy` → User), `Truck`, `Carrier`, `Driver`, `Vehicle`.
- `Address` : champ `label` nullable (déprécié, retiré du front et du `address:write`), expose `fullAddress` via getter. `countryCode` par défaut `FR` côté front.

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.79'
app.version: '0.0.81'

View 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
}
}

View File

@@ -89,6 +89,9 @@
"create": "Impossible de créer le type bovin.",
"update": "Impossible de mettre à jour le type bovin."
},
"bovine": {
"create": "Impossible d'enregistrer le bovin."
},
"carrier": {
"list": "Impossible de récupérer la liste des transporteurs.",
"fetch": "Impossible de récupérer les données du transporteur",
@@ -146,6 +149,9 @@
"update": "Type bovin mis à jour avec succès.",
"create": "Type bovin créé avec succès."
},
"bovine": {
"create": "Bovin enregistré avec succès."
},
"weight": {
"update": "Pesée mis à jour"
}

View File

@@ -122,6 +122,23 @@
Bovins
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
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>
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
@@ -218,6 +235,9 @@
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
Bovins
</NuxtLink>
<NuxtLink to="/scan" @click="closeMenu">
Scanner
</NuxtLink>
</nav>
<button
@@ -231,7 +251,7 @@
</aside>
</transition>
</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/>
</main>
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">

View File

@@ -4,7 +4,7 @@
:columns="columns"
:items="receptionList ?? []"
route-prefix="/reception"
show-actions
:show-actions="auth.isAdmin"
>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
@@ -23,6 +23,9 @@
<script setup lang="ts">
import type { ReceptionData } from '~/services/dto/reception-data'
import { getReceptionList, deleteReception } from '~/services/reception'
import { useAuthStore } from '~/stores/auth'
const auth = useAuthStore()
const columns = [
{ key: 'receptionDate', label: 'Date et heure' },

229
frontend/pages/scan.vue Normal file
View 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>

View File

@@ -4,7 +4,7 @@
:columns="columns"
:items="shipmentList ?? []"
route-prefix="/shipment"
show-actions
:show-actions="auth.isAdmin"
>
<template #cell-shipmentDate="{ item }">
{{ formatDate(item.shipmentDate) }}
@@ -35,6 +35,9 @@
<script setup lang="ts">
import type { ShipmentData } from '~/services/dto/shipment-data'
import { getShipmentList, deleteShipment } from '~/services/shipment'
import { useAuthStore } from '~/stores/auth'
const auth = useAuthStore()
const columns = [
{ key: 'shipmentDate', label: 'Date et heure' },

View 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 }
}

View 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
}

View File

@@ -1,8 +1,10 @@
import type { BuildingLayoutData } from '~/services/dto/building-layout-data'
import type { BuildingCaseData } from '~/services/dto/building-case-data'
export interface BuildingData {
id: number
label: string
code: string
layouts?: BuildingLayoutData[] | null
buildingCases?: BuildingCaseData[] | null
}

View File

@@ -53,6 +53,8 @@ class Building
* @var Collection<int, BuildingCase>
*/
#[ORM\OneToMany(targetEntity: BuildingCase::class, mappedBy: 'id_building')]
#[Groups(['building:read'])]
#[SerializedName('buildingCases')]
private Collection $buildingCases;
/**

View File

@@ -17,6 +17,10 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['building:read']],
),
new Get(
uriTemplate: '/building_cases/{id}/weights-report',
requirements: ['id' => '\d+'],