feat(bovine) : suivi des mouvements internes (bâtiment/case)
- Entité BovineMovement (bovine, buildingCase|building, enteredAt, leftAt) + relation OneToMany sur Bovine ordonnée DESC - Endpoint POST /api/bovine_movements via BovineMovementProcessor : ferme le mouvement courant, ouvre le nouveau, synchronise bovine.buildingCase - Commande idempotente app:backfill-bovine-movements pour initialiser l'historique des bovins existants - Onglet Mouvement de la page Vie du bovin : form 3 colonnes (style admin) + UiDataTable avec filtres header (Bâtiment, Case actifs ; Du/Au/Durée désactivés) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,78 @@
|
||||
]"
|
||||
/>
|
||||
|
||||
<div v-show="activeTab === 'mouvement'">
|
||||
<form :class="{ submitted: movementSubmitted }" @submit.prevent="submitMovement">
|
||||
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
|
||||
<UiSelect
|
||||
id="movement-building"
|
||||
v-model="newMovementBuildingId"
|
||||
label="Bâtiment"
|
||||
:options="buildingOptions"
|
||||
wrapper-class="w-[280px]"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
id="movement-case"
|
||||
v-model="newMovementCaseId"
|
||||
label="Case"
|
||||
:options="caseOptions"
|
||||
:disabled="!newMovementBuildingId"
|
||||
wrapper-class="w-[280px]"
|
||||
required
|
||||
/>
|
||||
<div class="w-[280px]" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center mb-11">
|
||||
<UiButton
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center gap-2 text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80"
|
||||
:disabled="isSubmittingMovement"
|
||||
:loading="isSubmittingMovement"
|
||||
@click="movementSubmitted = true"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<UiDataTable
|
||||
:columns="movementColumns"
|
||||
:items="filteredMovementRows"
|
||||
:per-page="10"
|
||||
>
|
||||
<template #header-building>
|
||||
<UiTextInput
|
||||
v-model="movementFilters.building"
|
||||
placeholder="Bâtiment"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-case>
|
||||
<UiTextInput
|
||||
v-model="movementFilters.case"
|
||||
placeholder="Case"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-enteredAt>
|
||||
<UiTextInput :model-value="''" placeholder="Du" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-leftAt>
|
||||
<UiTextInput :model-value="''" placeholder="Au" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-duration>
|
||||
<UiTextInput :model-value="''" placeholder="Durée" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-leftAt="{ item }">
|
||||
<span v-if="item.leftAt">{{ item.leftAt }}</span>
|
||||
<span v-else class="italic text-slate-500">En cours</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -78,10 +150,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getBuildingList } from '~/services/building'
|
||||
import type { BuildingData } from '~/services/dto/building-data'
|
||||
|
||||
useHead({ title: 'Vie du bovin' })
|
||||
|
||||
type BovineTab = 'mouvement' | 'passeport' | 'sante'
|
||||
const activeTab = ref<BovineTab>('passeport')
|
||||
const activeTab = ref<BovineTab>('mouvement')
|
||||
|
||||
interface BovineTypeRef {
|
||||
id: number
|
||||
@@ -89,17 +164,37 @@ interface BovineTypeRef {
|
||||
code: string | null
|
||||
}
|
||||
|
||||
interface BuildingRef {
|
||||
label: string | null
|
||||
}
|
||||
|
||||
interface BuildingCaseRef {
|
||||
caseNumber: number | null
|
||||
building: BuildingRef | null
|
||||
}
|
||||
|
||||
interface BovineMovementData {
|
||||
id: number
|
||||
enteredAt: string
|
||||
leftAt: string | null
|
||||
buildingCase: BuildingCaseRef | null
|
||||
building: BuildingRef | null
|
||||
}
|
||||
|
||||
interface BovinePassportData {
|
||||
id: number
|
||||
nationalNumber: string
|
||||
workNumber: string | null
|
||||
sex: string | null
|
||||
birthDate: string | null
|
||||
exitedAt: string | null
|
||||
exitDate: string | null
|
||||
bovineType: BovineTypeRef | null
|
||||
motherNationalNumber: string | null
|
||||
motherBovineType: BovineTypeRef | null
|
||||
fatherNationalNumber: string | null
|
||||
fatherBovineType: BovineTypeRef | null
|
||||
movements: BovineMovementData[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
@@ -107,6 +202,12 @@ const route = useRoute()
|
||||
const api = useApi()
|
||||
|
||||
const bovine = ref<BovinePassportData | null>(null)
|
||||
const buildings = ref<BuildingData[]>([])
|
||||
const newMovementBuildingId = ref<string | number | null>(null)
|
||||
const newMovementCaseId = ref<string | number | null>(null)
|
||||
const isSubmittingMovement = ref(false)
|
||||
const movementSubmitted = ref(false)
|
||||
const movementFilters = ref({ building: '', case: '' })
|
||||
|
||||
const bovineId = computed(() => {
|
||||
const raw = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
@@ -128,8 +229,93 @@ const formatDate = (date: string | null | undefined) => {
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
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(newMovementBuildingId.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(newMovementBuildingId, () => {
|
||||
newMovementCaseId.value = null
|
||||
})
|
||||
|
||||
const movementColumns = [
|
||||
{ key: 'building', label: 'Bâtiment' },
|
||||
{ key: 'case', label: 'Case' },
|
||||
{ key: 'enteredAt', label: 'Du' },
|
||||
{ key: 'leftAt', label: 'Au' },
|
||||
{ key: 'duration', label: 'Durée' }
|
||||
]
|
||||
|
||||
const movementEndDate = (movement: BovineMovementData): string | null => {
|
||||
return movement.leftAt ?? bovine.value?.exitedAt ?? bovine.value?.exitDate ?? null
|
||||
}
|
||||
|
||||
const formatDuration = (movement: BovineMovementData): string => {
|
||||
const start = new Date(movement.enteredAt)
|
||||
if (isNaN(start.getTime())) return '—'
|
||||
const endRaw = movementEndDate(movement)
|
||||
const end = endRaw ? new Date(endRaw) : new Date()
|
||||
if (isNaN(end.getTime())) return '—'
|
||||
const days = Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86_400_000))
|
||||
return `${days} j`
|
||||
}
|
||||
|
||||
const movementRows = computed(() => {
|
||||
const list = bovine.value?.movements ?? []
|
||||
return list.map(m => ({
|
||||
id: m.id,
|
||||
building: m.buildingCase?.building?.label ?? m.building?.label ?? '—',
|
||||
case: m.buildingCase?.caseNumber != null ? `Case ${m.buildingCase.caseNumber}` : '—',
|
||||
enteredAt: formatDate(m.enteredAt),
|
||||
leftAt: m.leftAt ? formatDate(m.leftAt) : null,
|
||||
duration: formatDuration(m)
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredMovementRows = computed(() => {
|
||||
const buildingFilter = movementFilters.value.building.trim().toLowerCase()
|
||||
const caseFilter = movementFilters.value.case.trim().toLowerCase()
|
||||
return movementRows.value.filter(row => {
|
||||
if (buildingFilter && !row.building.toLowerCase().includes(buildingFilter)) return false
|
||||
if (caseFilter && !row.case.toLowerCase().includes(caseFilter)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const submitMovement = async () => {
|
||||
if (!newMovementCaseId.value || bovineId.value === null) return
|
||||
isSubmittingMovement.value = true
|
||||
try {
|
||||
await api.post('bovine_movements', {
|
||||
bovine: `/api/bovines/${bovineId.value}`,
|
||||
buildingCase: `/api/building_cases/${newMovementCaseId.value}`
|
||||
}, { toastSuccessMessage: 'Mouvement enregistré' })
|
||||
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
|
||||
newMovementBuildingId.value = null
|
||||
newMovementCaseId.value = null
|
||||
movementSubmitted.value = false
|
||||
} finally {
|
||||
isSubmittingMovement.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (bovineId.value === null) return
|
||||
bovine.value = await api.get<BovinePassportData>(`bovines/${bovineId.value}`)
|
||||
const [bovineData, buildingList] = await Promise.all([
|
||||
api.get<BovinePassportData>(`bovines/${bovineId.value}`),
|
||||
getBuildingList()
|
||||
])
|
||||
bovine.value = bovineData
|
||||
buildings.value = buildingList
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user