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="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
|
||||||
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
|
||||||
title="Exporter en Excel"
|
title="Exporter en Excel"
|
||||||
@click="exportInventory"
|
@click="showExportModal = true"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
@@ -136,12 +136,19 @@
|
|||||||
</template>
|
</template>
|
||||||
</UiDataTable>
|
</UiDataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InventoryExportModal
|
||||||
|
v-model="showExportModal"
|
||||||
|
:loading="exporting"
|
||||||
|
@submit="exportInventory"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BovineData } from '~/services/dto/bovine-data'
|
import type { BovineData } from '~/services/dto/bovine-data'
|
||||||
|
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
import { useBovineColumns } from '~/composables/useBovineColumns'
|
import { useBovineColumns } from '~/composables/useBovineColumns'
|
||||||
@@ -183,12 +190,17 @@ const loadStats = async () => {
|
|||||||
|
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
|
const showExportModal = ref(false)
|
||||||
|
|
||||||
const exportInventory = async () => {
|
const exportInventory = async (filters: InventoryExportFilters) => {
|
||||||
if (exporting.value) return
|
if (exporting.value) return
|
||||||
exporting.value = true
|
exporting.value = true
|
||||||
try {
|
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 filename = `inventaire_bovins_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
@@ -199,6 +211,7 @@ const exportInventory = async () => {
|
|||||||
a.click()
|
a.click()
|
||||||
a.remove()
|
a.remove()
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||||
|
showExportModal.value = false
|
||||||
} catch {
|
} catch {
|
||||||
// toast déjà géré par useApi onResponseError
|
// toast déjà géré par useApi onResponseError
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -13,11 +13,59 @@ use Doctrine\Persistence\ManagerRegistry;
|
|||||||
*/
|
*/
|
||||||
final class BovineRepository extends ServiceEntityRepository
|
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)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
parent::__construct($registry, Bovine::class);
|
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.
|
* Compteurs des bovins actifs par tranche d'âge.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ namespace App\State\Bovin;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Entity\Bovine;
|
use App\Entity\Bovine;
|
||||||
|
use App\Repository\BovineRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
@@ -16,6 +16,7 @@ use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
|||||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
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];
|
private const COLUMN_WIDTHS = [18, 12, 10, 12, 12, 12, 30, 8, 12];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $em,
|
private BovineRepository $bovineRepository,
|
||||||
|
private RequestStack $requestStack,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
{
|
{
|
||||||
$bovines = $this->em->createQueryBuilder()
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
->select('b')
|
$raw = (string) ($request?->query->get('ageRanges') ?? '');
|
||||||
->from(Bovine::class, 'b')
|
$ageRanges = '' === $raw ? [] : array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
->where('b.exitedAt IS NULL')
|
|
||||||
->orderBy('b.birthDate', 'ASC')
|
$bovines = $this->bovineRepository->findActiveForInventoryExport($ageRanges);
|
||||||
->getQuery()
|
|
||||||
->getResult()
|
|
||||||
;
|
|
||||||
|
|
||||||
$spreadsheet = $this->buildSpreadsheet($bovines);
|
$spreadsheet = $this->buildSpreadsheet($bovines);
|
||||||
$body = $this->renderXlsx($spreadsheet);
|
$body = $this->renderXlsx($spreadsheet);
|
||||||
|
|||||||
Reference in New Issue
Block a user