feat(bovine) : page Vie du bovin + tabs réutilisables + parents EDNOTIF

- Nouvelle page /bovine/[id] avec tabs Mouvement / Passeport bovin / Santé
- Composant UiTabs partagé, réutilisé sur réception et expédition
- Champs père/mère (numéro national + type de race) sur Bovine, alimentés via la sync EDNOTIF
- Inventaire : ligne cliquable vers le passeport

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 11:59:23 +02:00
parent 9e67a5e289
commit 642ee43c53
8 changed files with 298 additions and 54 deletions

View File

@@ -0,0 +1,35 @@
<template>
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
v-for="tab in tabs"
:key="tab.key"
class="font-bold text-3xl uppercase px-12 cursor-pointer"
:class="[
modelValue === tab.key
? 'border-b-[6px] border-primary-500 text-primary-500'
: 'text-primary-500/50',
tab.error ? '!text-red-500 !border-red-500' : ''
]"
@click="emit('update:modelValue', tab.key)"
>
{{ tab.label }}
</h1>
</div>
</template>
<script setup lang="ts" generic="T extends string">
export interface UiTab<K extends string = string> {
key: K
label: string
error?: boolean
}
defineProps<{
modelValue: T
tabs: UiTab<T>[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: T): void
}>()
</script>

View File

@@ -0,0 +1,135 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between relative mb-10">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/inventory')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="font-bold text-3xl uppercase text-primary-500">Vie du bovin</h1>
</div>
<UiTabs
v-model="activeTab"
:tabs="[
{ key: 'mouvement', label: 'Mouvement' },
{ key: 'passeport', label: 'Passeport bovin' },
{ key: 'sante', label: 'Santé' }
]"
/>
<div v-show="activeTab === 'passeport'">
<div class="mt-6">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Veau</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Sexe</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-b border-black px-2 py-1 text-center font-semibold text-sm">Date de naissance</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.nationalNumber) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.workNumber) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.sex) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.code) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.bovineType?.label) }}</div>
<div class="px-2 py-1 text-center">{{ formatDate(bovine?.birthDate) }}</div>
</div>
</div>
<div class="mt-9">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Père</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherNationalNumber) }}</div>
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.fatherNationalNumber)) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.code) }}</div>
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.fatherBovineType?.label) }}</div>
</div>
</div>
<div class="mt-9">
<div class="grid grid-cols-[3rem_repeat(6,minmax(0,1fr))] grid-rows-2 border-2 border-black">
<div class="row-span-2 flex items-center justify-center border-r-2 border-black">
<span class="uppercase font-bold -rotate-90 whitespace-nowrap transform-gpu">Mère</span>
</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Numéro national</div>
<div class="col-span-2 border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">N° de travail</div>
<div class="border-b border-r border-black px-2 py-1 text-center font-semibold text-sm">Code race</div>
<div class="col-span-2 border-b border-black px-2 py-1 text-center font-semibold text-sm">Type de race</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherNationalNumber) }}</div>
<div class="col-span-2 border-r border-black px-2 py-1 text-center">{{ display(workNumberFromNational(bovine?.motherNationalNumber)) }}</div>
<div class="border-r border-black px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.code) }}</div>
<div class="col-span-2 px-2 py-1 text-center">{{ display(bovine?.motherBovineType?.label) }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'Vie du bovin' })
type BovineTab = 'mouvement' | 'passeport' | 'sante'
const activeTab = ref<BovineTab>('passeport')
interface BovineTypeRef {
id: number
label: string | null
code: string | null
}
interface BovinePassportData {
id: number
nationalNumber: string
workNumber: string | null
sex: string | null
birthDate: string | null
bovineType: BovineTypeRef | null
motherNationalNumber: string | null
motherBovineType: BovineTypeRef | null
fatherNationalNumber: string | null
fatherBovineType: BovineTypeRef | null
}
const router = useRouter()
const route = useRoute()
const api = useApi()
const bovine = ref<BovinePassportData | null>(null)
const bovineId = computed(() => {
const raw = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const n = Number(raw)
return Number.isFinite(n) ? n : null
})
const display = (value: string | null | undefined) => (value && value !== '' ? value : '—')
const workNumberFromNational = (nationalNumber: string | null | undefined) => {
if (!nationalNumber) return null
return nationalNumber.slice(-4)
}
const formatDate = (date: string | null | undefined) => {
if (!date) return '—'
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
onMounted(async () => {
if (bovineId.value === null) return
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
})
</script>

View File

@@ -57,6 +57,8 @@
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="(item: BovineData) => router.push(`/bovine/${item.id}`)"
>
<template #header-nationalNumber>
<UiTextInput

View File

@@ -145,38 +145,14 @@
/>
</div>
<div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer"
:class="[
activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasMerchandiseTabError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'merchandise'"
>
{{ isMerchandise ? "Marchandise" : "Bovins" }}
</h1>
</div>
<UiTabs
v-model="activeTab"
:tabs="[
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError },
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
{ key: 'merchandise', label: isMerchandise ? 'Marchandise' : 'Bovins', error: hasMerchandiseTabError }
]"
/>
<div class="mb-12 ">
<update-weight
v-show="activeTab === 'weights'"

View File

@@ -146,28 +146,13 @@
</div>
<div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
</div>
<UiTabs
v-model="activeTab"
:tabs="[
{ key: 'weightsEmpty', label: 'pesée à vide', error: hasTareWeightError },
{ key: 'weights', label: 'pesée à plein', error: hasGrossWeightError }
]"
/>
<div class="mb-12">
<update-weight
v-show="activeTab === 'weights'"

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260504125011 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add mother/father national number and bovine type to bovine.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine ADD mother_national_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD father_national_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD mother_bovine_type_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD father_bovine_type_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F14E5E9FB FOREIGN KEY (mother_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F53F12909 FOREIGN KEY (father_bovine_type_id) REFERENCES bovine_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_2068337F14E5E9FB ON bovine (mother_bovine_type_id)');
$this->addSql('CREATE INDEX IDX_2068337F53F12909 ON bovine (father_bovine_type_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F14E5E9FB');
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F53F12909');
$this->addSql('DROP INDEX IDX_2068337F14E5E9FB');
$this->addSql('DROP INDEX IDX_2068337F53F12909');
$this->addSql('ALTER TABLE bovine DROP mother_national_number');
$this->addSql('ALTER TABLE bovine DROP father_national_number');
$this->addSql('ALTER TABLE bovine DROP mother_bovine_type_id');
$this->addSql('ALTER TABLE bovine DROP father_bovine_type_id');
}
}

View File

@@ -135,6 +135,24 @@ class Bovine
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read'])]
private ?string $motherNationalNumber = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?BovineType $motherBovineType = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read'])]
private ?string $fatherNationalNumber = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read'])]
#[ApiProperty(readableLink: true)]
private ?BovineType $fatherBovineType = null;
public function getId(): ?int
{
return $this->id;
@@ -329,6 +347,54 @@ class Bovine
return $this;
}
public function getMotherNationalNumber(): ?string
{
return $this->motherNationalNumber;
}
public function setMotherNationalNumber(?string $motherNationalNumber): static
{
$this->motherNationalNumber = $motherNationalNumber;
return $this;
}
public function getMotherBovineType(): ?BovineType
{
return $this->motherBovineType;
}
public function setMotherBovineType(?BovineType $motherBovineType): static
{
$this->motherBovineType = $motherBovineType;
return $this;
}
public function getFatherNationalNumber(): ?string
{
return $this->fatherNationalNumber;
}
public function setFatherNationalNumber(?string $fatherNationalNumber): static
{
$this->fatherNationalNumber = $fatherNationalNumber;
return $this;
}
public function getFatherBovineType(): ?BovineType
{
return $this->fatherBovineType;
}
public function setFatherBovineType(?BovineType $fatherBovineType): static
{
$this->fatherBovineType = $fatherBovineType;
return $this;
}
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function refreshAgeMonths(): void

View File

@@ -99,6 +99,11 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
}
$latestEntry = null;