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 - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #52 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
313 lines
11 KiB
Vue
313 lines
11 KiB
Vue
<template>
|
||
<div class="px-[86px]">
|
||
<div class="flex items-center justify-between relative">
|
||
<div class="flex flex-row absolute -left-[60px]">
|
||
<Icon
|
||
@click="router.push('/')"
|
||
name="gg:arrow-left-o"
|
||
size="44"
|
||
class="cursor-pointer text-primary-500"
|
||
/>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
|
||
<span class="text-lg text-slate-500">({{ totalItems }} bovin{{ totalItems > 1 ? 's' : '' }})</span>
|
||
<div
|
||
v-if="auth.isBureau"
|
||
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
|
||
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
||
title="Exporter en Excel"
|
||
@click="showExportModal = true"
|
||
>
|
||
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
||
</div>
|
||
</div>
|
||
<button
|
||
v-if="auth.isBureau"
|
||
type="button"
|
||
:disabled="syncing"
|
||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||
@click="syncInventory"
|
||
>
|
||
<Icon name="mdi:sync" size="28" :class="syncing ? 'animate-spin' : ''" />
|
||
Rafraîchir
|
||
</button>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap gap-3 mt-4">
|
||
<div class="flex items-center gap-3 rounded-md bg-red-500 px-4 py-2">
|
||
<span class="text-2xl font-bold text-white">{{ stats.over24 }}</span>
|
||
<span class="text-sm uppercase tracking-wide text-white">≥ 24 mois</span>
|
||
</div>
|
||
<div class="flex items-center gap-3 rounded-md bg-orange-500 px-4 py-2">
|
||
<span class="text-2xl font-bold text-white">{{ stats.between22And24 }}</span>
|
||
<span class="text-sm uppercase tracking-wide text-white">22 – 24 mois</span>
|
||
</div>
|
||
<div class="flex items-center gap-3 rounded-md bg-yellow-500 px-4 py-2">
|
||
<span class="text-2xl font-bold text-white">{{ stats.between20And22 }}</span>
|
||
<span class="text-sm uppercase tracking-wide text-white">20 – 22 mois</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-6 mb-8">
|
||
<UiDataTable
|
||
v-model:page="page"
|
||
v-model:per-page="perPage"
|
||
:columns="columns"
|
||
:items="items"
|
||
:total-items="totalItems"
|
||
:loading="loading"
|
||
>
|
||
<template #header-nationalNumber>
|
||
<UiTextInput
|
||
v-model="filters.nationalNumber"
|
||
placeholder="N° National"
|
||
size="compact"
|
||
/>
|
||
</template>
|
||
<template #header-workNumber>
|
||
<UiTextInput
|
||
v-model="filters.workNumber"
|
||
placeholder="N° Travail"
|
||
size="compact"
|
||
/>
|
||
</template>
|
||
<template #header-sex>
|
||
<UiSelect
|
||
v-model="filters.sex"
|
||
placeholder="Sexe"
|
||
:options="sexOptions"
|
||
size="compact"
|
||
/>
|
||
</template>
|
||
<template #header-birthDate>
|
||
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
|
||
</template>
|
||
<template #header-bovineType.label>
|
||
<UiTextInput
|
||
v-model="filters['bovineType.label']"
|
||
placeholder="Race"
|
||
size="compact"
|
||
/>
|
||
</template>
|
||
<template #header-arrivalDate>
|
||
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
|
||
</template>
|
||
<template #header-buildingCase.building.label>
|
||
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
|
||
</template>
|
||
<template #header-buildingCase.caseNumber>
|
||
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
|
||
</template>
|
||
<template #header-age>
|
||
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
|
||
</template>
|
||
<template #header-pricePerKg>
|
||
<UiTextInput :model-value="''" placeholder="Prix/kg" size="compact" disabled />
|
||
</template>
|
||
<template #header-finalPrice>
|
||
<UiTextInput :model-value="''" placeholder="Prix total" size="compact" disabled />
|
||
</template>
|
||
<template #cell-birthDate="{ item }">
|
||
{{ formatDate(item.birthDate) }}
|
||
</template>
|
||
<template #cell-age="{ item }">
|
||
<span
|
||
class="inline-block rounded px-2 py-0.5 font-semibold"
|
||
:class="ageBadgeClass(item.ageMonths)"
|
||
>
|
||
{{ formatAgeLabel(item.ageMonths) }}
|
||
</span>
|
||
</template>
|
||
<template #cell-arrivalDate="{ item }">
|
||
{{ formatDate(item.arrivalDate) }}
|
||
</template>
|
||
<template #cell-buildingCase.building.label="{ item }">
|
||
{{ item.effectiveBuilding?.label ?? '—' }}
|
||
</template>
|
||
<template #cell-buildingCase.caseNumber="{ item }">
|
||
{{ item.buildingCase?.caseNumber ?? '—' }}
|
||
</template>
|
||
<template #cell-pricePerKg="{ item }">
|
||
{{ formatPrice(item.pricePerKg) }}
|
||
</template>
|
||
<template #cell-finalPrice="{ item }">
|
||
{{ formatPrice(item.finalPrice) }}
|
||
</template>
|
||
</UiDataTable>
|
||
</div>
|
||
|
||
<InventoryExportModal
|
||
v-model="showExportModal"
|
||
:loading="exporting"
|
||
@submit="exportInventory"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
|
||
<script setup lang="ts">
|
||
import type { BovineData } from '~/services/dto/bovine-data'
|
||
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
|
||
import { useAuthStore } from '~/stores/auth'
|
||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||
import { formatAgeLabel, ageBadgeClass } from '~/utils/bovine-age'
|
||
|
||
const router = useRouter()
|
||
const auth = useAuthStore()
|
||
const api = useApi()
|
||
const toast = useToast()
|
||
|
||
interface SyncResult {
|
||
created: number
|
||
updated: number
|
||
exited: number
|
||
total: number
|
||
}
|
||
|
||
interface InventoryStats {
|
||
total: number
|
||
over24: number
|
||
between22And24: number
|
||
between20And22: number
|
||
}
|
||
|
||
const stats = ref<InventoryStats>({
|
||
total: 0,
|
||
over24: 0,
|
||
between22And24: 0,
|
||
between20And22: 0
|
||
})
|
||
|
||
const loadStats = async () => {
|
||
try {
|
||
stats.value = await api.get<InventoryStats>('bovines/inventory-stats', {}, { toast: false })
|
||
} catch {
|
||
// silencieux : l'écran reste utilisable sans la légende
|
||
}
|
||
}
|
||
|
||
const syncing = ref(false)
|
||
const exporting = ref(false)
|
||
const showExportModal = ref(false)
|
||
|
||
const exportInventory = async (filters: InventoryExportFilters) => {
|
||
if (exporting.value) return
|
||
exporting.value = true
|
||
try {
|
||
const query: Record<string, unknown> = {}
|
||
if (filters.ageRanges.length > 0) {
|
||
query.ageRanges = filters.ageRanges.join(',')
|
||
}
|
||
const blob = await api.getBlob('bovines/inventory-export', query)
|
||
const filename = `inventaire_bovins_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = filename
|
||
a.style.display = 'none'
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||
showExportModal.value = false
|
||
} catch {
|
||
// toast déjà géré par useApi onResponseError
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
const syncInventory = async () => {
|
||
if (syncing.value) return
|
||
const confirmed = window.confirm(
|
||
"Lancer la synchronisation avec EDNOTIF ?\n\nLes bovins absents de la réponse seront marqués comme sortis."
|
||
)
|
||
if (!confirmed) return
|
||
|
||
syncing.value = true
|
||
try {
|
||
const result = await api.post<SyncResult>('bovines/sync-inventory')
|
||
toast.success({
|
||
title: 'Inventaire synchronisé',
|
||
message: `Créés : ${result.created} · Mis à jour : ${result.updated} · Sortis : ${result.exited} · Total EDNOTIF : ${result.total}`
|
||
})
|
||
reload()
|
||
loadStats()
|
||
} catch {
|
||
// error toast already handled by useApi onResponseError
|
||
} finally {
|
||
syncing.value = false
|
||
}
|
||
}
|
||
|
||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||
useDataTableServerState<BovineData>(
|
||
'bovines',
|
||
{
|
||
'exists[exitedAt]': 'false',
|
||
nationalNumber: '',
|
||
workNumber: '',
|
||
'bovineType.label': '',
|
||
sex: '',
|
||
'arrivalDate[after]': '',
|
||
'arrivalDate[strictly_before]': '',
|
||
'birthDate[after]': '',
|
||
'birthDate[strictly_before]': ''
|
||
}
|
||
)
|
||
|
||
const sexOptions = [
|
||
{ value: 'M', label: 'Mâle' },
|
||
{ value: 'F', label: 'Femelle' }
|
||
]
|
||
|
||
const addOneDay = (dateString: string): string => {
|
||
const [year, month, day] = dateString.split('-').map(Number)
|
||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||
return next.toISOString().slice(0, 10)
|
||
}
|
||
|
||
const singleDateFilter = (afterKey: string, beforeKey: string) =>
|
||
computed<string>({
|
||
get: () => (filters.value[afterKey] as string) ?? '',
|
||
set: (value: string) => {
|
||
if (!value) {
|
||
filters.value[afterKey] = ''
|
||
filters.value[beforeKey] = ''
|
||
return
|
||
}
|
||
filters.value[afterKey] = value
|
||
filters.value[beforeKey] = addOneDay(value)
|
||
}
|
||
})
|
||
|
||
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
||
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
||
|
||
const { columns } = useBovineColumns()
|
||
|
||
const formatDate = (date: string | null) => {
|
||
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'
|
||
})
|
||
}
|
||
|
||
const formatPrice = (price: number | null) => {
|
||
if (price === null || price === undefined) return '—'
|
||
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||
}
|
||
|
||
|
||
onMounted(() => {
|
||
reload()
|
||
loadStats()
|
||
})
|
||
</script>
|