feat : modal d'export inventaire avec filtres par tranches d'âge
- UiModal : composant générique réutilisable (teleport, escape, backdrop, max-width configurable) - InventoryExportModal : 3 checkboxes pour les tranches d'âge, footer centré sans annuler - BovineRepository::findActiveForInventoryExport(?array $ageRanges) en DQL - Endpoint inventory-export accepte ageRanges (comma-separated) en query param - Aucune coche = export complet (comportement actuel intact) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
96
frontend/components/inventory/inventory-export-modal.vue
Normal file
96
frontend/components/inventory/inventory-export-modal.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<UiModal v-model="open" title="Exporter l'inventaire bovin" max-width="max-w-2xl">
|
||||
<p class="mb-5 text-sm text-slate-600">
|
||||
Aucun filtre coché : export complet (tous les bovins actifs).
|
||||
</p>
|
||||
|
||||
<div class="mb-5">
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-600">
|
||||
Tranches d'âge
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="bucket in ageBuckets"
|
||||
:key="bucket.value"
|
||||
class="flex items-center gap-3 cursor-pointer text-primary-700"
|
||||
>
|
||||
<input
|
||||
v-model="filters.ageRanges"
|
||||
type="checkbox"
|
||||
:value="bucket.value"
|
||||
class="h-4 w-4 cursor-pointer accent-primary-500"
|
||||
/>
|
||||
<span :class="['inline-block rounded px-2 py-0.5 text-xs font-semibold text-white', bucket.colorClass]">
|
||||
{{ bucket.badge }}
|
||||
</span>
|
||||
<span>{{ bucket.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex h-[50px] items-center justify-center gap-2 rounded bg-primary-500 px-6 text-base text-white uppercase hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="onSubmit"
|
||||
>
|
||||
<Icon
|
||||
v-if="loading"
|
||||
name="mdi:loading"
|
||||
size="20"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<Icon v-else name="mdi:file-excel-outline" size="20" />
|
||||
Exporter
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
export interface InventoryExportFilters {
|
||||
ageRanges: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'submit', filters: InventoryExportFilters): void
|
||||
}>()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const ageBuckets = [
|
||||
{ value: 'over24', label: '≥ 24 mois', badge: '24+', colorClass: 'bg-red-500' },
|
||||
{ value: 'between22And24', label: '22 à 24 mois', badge: '22-24', colorClass: 'bg-orange-500' },
|
||||
{ value: 'between20And22', label: '20 à 22 mois', badge: '20-22', colorClass: 'bg-yellow-500' }
|
||||
]
|
||||
|
||||
const filters = reactive<InventoryExportFilters>({
|
||||
ageRanges: []
|
||||
})
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
filters.ageRanges = []
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = () => {
|
||||
emit('submit', { ageRanges: [...filters.ageRanges] })
|
||||
}
|
||||
</script>
|
||||
96
frontend/components/ui/UiModal.vue
Normal file
96
frontend/components/ui/UiModal.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@mousedown.self="closeOnBackdrop"
|
||||
>
|
||||
<div
|
||||
class="w-full rounded-md bg-white shadow-2xl"
|
||||
:class="maxWidth"
|
||||
@mousedown.stop
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500">{{ title }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-slate-500 hover:text-primary-500 flex items-center"
|
||||
aria-label="Fermer"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="border-t border-slate-200 px-6 py-4"
|
||||
>
|
||||
<slot name="footer" :close="close" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
closeOnBackdropClick?: boolean
|
||||
maxWidth?: string
|
||||
}>(), {
|
||||
title: '',
|
||||
closeOnBackdropClick: true,
|
||||
maxWidth: 'max-w-lg'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const close = () => emit('update:modelValue', false)
|
||||
|
||||
const closeOnBackdrop = () => {
|
||||
if (props.closeOnBackdropClick) close()
|
||||
}
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.modelValue) close()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -17,7 +17,7 @@
|
||||
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="exportInventory"
|
||||
@click="showExportModal = true"
|
||||
>
|
||||
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
||||
</div>
|
||||
@@ -136,12 +136,19 @@
|
||||
</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'
|
||||
@@ -183,12 +190,17 @@ const loadStats = async () => {
|
||||
|
||||
const syncing = ref(false)
|
||||
const exporting = ref(false)
|
||||
const showExportModal = ref(false)
|
||||
|
||||
const exportInventory = async () => {
|
||||
const exportInventory = async (filters: InventoryExportFilters) => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await api.getBlob('bovines/inventory-export')
|
||||
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')
|
||||
@@ -199,6 +211,7 @@ const exportInventory = async () => {
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
showExportModal.value = false
|
||||
} catch {
|
||||
// toast déjà géré par useApi onResponseError
|
||||
} finally {
|
||||
|
||||
@@ -13,11 +13,59 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
*/
|
||||
final class BovineRepository extends ServiceEntityRepository
|
||||
{
|
||||
public const AGE_RANGE_OVER_24 = 'over24';
|
||||
|
||||
public const AGE_RANGE_BETWEEN_22_AND_24 = 'between22And24';
|
||||
|
||||
public const AGE_RANGE_BETWEEN_20_AND_22 = 'between20And22';
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Bovine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des bovins actifs pour l'export inventaire.
|
||||
*
|
||||
* @param null|list<string> $ageRanges Si null/vide → tous. Sinon filtre OR sur les tranches d'âge demandées.
|
||||
*
|
||||
* @return list<Bovine>
|
||||
*/
|
||||
public function findActiveForInventoryExport(?array $ageRanges = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->where('b.exitedAt IS NULL')
|
||||
->orderBy('b.birthDate', 'ASC')
|
||||
;
|
||||
|
||||
if (null !== $ageRanges && [] !== $ageRanges) {
|
||||
$orX = $qb->expr()->orX();
|
||||
foreach ($ageRanges as $idx => $range) {
|
||||
switch ($range) {
|
||||
case self::AGE_RANGE_OVER_24:
|
||||
$orX->add('b.ageMonths >= 24');
|
||||
|
||||
break;
|
||||
|
||||
case self::AGE_RANGE_BETWEEN_22_AND_24:
|
||||
$orX->add($qb->expr()->andX('b.ageMonths >= 22', 'b.ageMonths < 24'));
|
||||
|
||||
break;
|
||||
|
||||
case self::AGE_RANGE_BETWEEN_20_AND_22:
|
||||
$orX->add($qb->expr()->andX('b.ageMonths >= 20', 'b.ageMonths < 22'));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($orX->count() > 0) {
|
||||
$qb->andWhere($orX);
|
||||
}
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compteurs des bovins actifs par tranche d'âge.
|
||||
*
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace App\State\Bovin;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\Bovine;
|
||||
use App\Repository\BovineRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
@@ -16,6 +16,7 @@ use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
@@ -46,19 +47,17 @@ final class BovineInventoryExportProvider implements ProviderInterface
|
||||
private const COLUMN_WIDTHS = [18, 12, 10, 12, 12, 12, 30, 8, 12];
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private BovineRepository $bovineRepository,
|
||||
private RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
{
|
||||
$bovines = $this->em->createQueryBuilder()
|
||||
->select('b')
|
||||
->from(Bovine::class, 'b')
|
||||
->where('b.exitedAt IS NULL')
|
||||
->orderBy('b.birthDate', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$raw = (string) ($request?->query->get('ageRanges') ?? '');
|
||||
$ageRanges = '' === $raw ? [] : array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||
|
||||
$bovines = $this->bovineRepository->findActiveForInventoryExport($ageRanges);
|
||||
|
||||
$spreadsheet = $this->buildSpreadsheet($bovines);
|
||||
$body = $this->renderXlsx($spreadsheet);
|
||||
|
||||
Reference in New Issue
Block a user