Compare commits

...

7 Commits

Author SHA1 Message Date
gitea-actions
104942a52b chore : bump version to v1.9.38
All checks were successful
Build & Push Docker Image / build (push) Successful in 2m53s
Auto Tag Develop / tag (push) Successful in 9s
2026-05-21 14:28:44 +00:00
Matthieu
c65757ee24 feat(vue-ensemble) : tri alphabétique des machines par défaut + select de tri (nom/date) + harmonisation tailles des champs de filtre
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:23:40 +02:00
Matthieu
6e105fd070 chore : bump version to v1.9.37
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 10:50:20 +02:00
Matthieu
a0c4597de0 fix(fournisseurs) : ConstructeurSearchFilter utilise EXISTS subquery au lieu de LEFT JOIN
Le LEFT JOIN sur telephones causait une erreur PostgreSQL 'column must appear in GROUP BY' parce que Doctrine sélectionnait aussi les colonnes des téléphones joints. EXISTS subquery corrélée évite la duplication de lignes sans introduire de GROUP BY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:49:43 +02:00
Matthieu
d3f269452c chore : bump version to v1.9.36
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-13 10:46:51 +02:00
gitea-actions
b3fa927e77 chore : bump version to v1.9.35
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 08:44:31 +00:00
Matthieu
f71f4c68da feat(fournisseurs) : pagination serveur + search multi-champs (name/email/telephone) + filtre catégorie + tri
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend
- Nouveau ConstructeurSearchFilter : LIKE insensible casse sur name/email + LEFT JOIN telephones.numero, accessible via ?search=
- Constructeur entity : ApiFilter ConstructeurSearchFilter, SearchFilter (categories.id exact), OrderFilter (name, email, createdAt)
- paginationMaximumItemsPerPage 200 -> 2000 (pour ConstructeurSelect et MachineDetail qui chargent l'ensemble en cache)

Frontend
- useConstructeurs : nouvelle fonction fetchConstructeursPage({ page, itemsPerPage, search, categoryId, orderField, orderDirection }) renvoyant { items, totalItems, totalPages, currentPage }
- constructeurs.vue : suppression du filtre/tri client, état page/perPage/totalItems/totalPages, watchers sur search/filter/sort qui reset page=1 et rechargent, prop pagination du DataTable câblée, recharge après create/update/delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:44:20 +02:00
6 changed files with 258 additions and 36 deletions

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.34' app.version: '1.9.38'

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from './useApi' import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface ConstructeurTelephone { export interface ConstructeurTelephone {
'@id'?: string '@id'?: string
@@ -33,6 +33,24 @@ interface ConstructeurResult {
error?: string error?: string
} }
export interface ConstructeurPageOptions {
page?: number
itemsPerPage?: number
search?: string
categoryId?: string
orderField?: 'name' | 'email' | 'createdAt'
orderDirection?: 'asc' | 'desc'
}
export interface ConstructeurPageResult {
success: boolean
items: Constructeur[]
totalItems: number
totalPages: number
currentPage: number
error?: string
}
const constructeurs = ref<Constructeur[]>([]) const constructeurs = ref<Constructeur[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
@@ -83,8 +101,10 @@ export function useConstructeurs() {
} }
loading.value = true loading.value = true
try { try {
const query = search ? `?search=${encodeURIComponent(search)}` : '' const params = new URLSearchParams()
const result = await get(`/constructeurs${query}`) params.set('itemsPerPage', '2000')
if (search) params.set('search', search)
const result = await get(`/constructeurs?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items) constructeurs.value = uniqueConstructeurs(items)
@@ -104,6 +124,37 @@ export function useConstructeurs() {
return loadConstructeurs(search) return loadConstructeurs(search)
} }
const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
const page = Math.max(1, opts.page ?? 1)
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
loading.value = true
try {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('itemsPerPage', String(itemsPerPage))
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
if (opts.categoryId) params.set('categories.id', opts.categoryId)
if (opts.orderField) {
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
}
const result = await get(`/constructeurs?${params.toString()}`)
if (!result.success) {
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
}
const items = extractCollection<Constructeur>(result.data)
const totalItems = extractTotal(result.data, items.length)
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
upsertConstructeurs(items)
return { success: true, items, totalItems, totalPages, currentPage: page }
} catch (error) {
const err = error as Error
console.error('Erreur lors du chargement de la page fournisseurs:', error)
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
} finally {
loading.value = false
}
}
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => { const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
loading.value = true loading.value = true
try { try {
@@ -227,6 +278,7 @@ export function useConstructeurs() {
loading, loading,
loadConstructeurs, loadConstructeurs,
searchConstructeurs, searchConstructeurs,
fetchConstructeursPage,
createConstructeur, createConstructeur,
updateConstructeur, updateConstructeur,
deleteConstructeur, deleteConstructeur,

View File

@@ -19,13 +19,17 @@
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<DataTable <DataTable
:columns="columns" :columns="columns"
:rows="filteredConstructeurs" :rows="pageItems"
:loading="loading" :loading="loading"
:sort="currentSort" :sort="currentSort"
:show-counter="false" :pagination="paginationState"
:show-counter="true"
:show-per-page="true"
empty-message="Aucun fournisseur trouvé." empty-message="Aucun fournisseur trouvé."
no-results-message="Aucun fournisseur trouvé." no-results-message="Aucun fournisseur trouvé."
@sort="handleSort" @sort="handleSort"
@update:current-page="onPageChange"
@update:per-page="onPerPageChange"
> >
<template #toolbar> <template #toolbar>
<div class="flex flex-col sm:flex-row gap-3 w-full"> <div class="flex flex-col sm:flex-row gap-3 w-full">
@@ -204,7 +208,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue' import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue' import FieldPhone from '~/components/form/FieldPhone.vue'
@@ -229,10 +233,17 @@ interface ConstructeurFormState {
const api = useApi() const api = useApi()
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs() const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
const { categories: allCategories, loadCategories } = useConstructeurCategories() const { categories: allCategories, loadCategories } = useConstructeurCategories()
const { showError } = useToast() const { showError } = useToast()
const pageItems = ref<typeof constructeurs.value>([])
const totalItems = ref(0)
const totalPages = ref(0)
const currentPage = ref(1)
const perPage = ref(30)
const perPageOptions = [15, 30, 50, 100]
const columns = [ const columns = [
{ key: 'name', label: 'Nom', sortable: true }, { key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true }, { key: 'email', label: 'Email', sortable: true },
@@ -261,6 +272,44 @@ const handleSort = (sort) => {
sortDir.value = sort.direction sortDir.value = sort.direction
} }
const paginationState = computed(() => ({
currentPage: currentPage.value,
totalPages: totalPages.value,
totalItems: totalItems.value,
pageItems: pageItems.value.length,
perPage: perPage.value,
perPageOptions,
}))
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
const loadPage = async () => {
const orderField = SORTABLE_FIELDS.has(sortKey.value)
? (sortKey.value as 'name' | 'email' | 'createdAt')
: 'name'
const result = await fetchConstructeursPage({
page: currentPage.value,
itemsPerPage: perPage.value,
search: searchTerm.value,
categoryId: selectedCategoryId.value || undefined,
orderField,
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
})
if (!result.success) {
if (result.error) showError(result.error)
pageItems.value = []
totalItems.value = 0
totalPages.value = 0
return
}
pageItems.value = result.items
totalItems.value = result.totalItems
totalPages.value = result.totalPages
if (currentPage.value > result.totalPages && result.totalPages > 0) {
currentPage.value = result.totalPages
}
}
const modalOpen = ref(false) const modalOpen = ref(false)
const saving = ref(false) const saving = ref(false)
const editingConstructeur = ref<Record<string, any> | null>(null) const editingConstructeur = ref<Record<string, any> | null>(null)
@@ -268,29 +317,31 @@ const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], c
const rowPhones = constructeurPhones const rowPhones = constructeurPhones
const filteredConstructeurs = computed(() => { const debouncedSearch = debounce(() => {
const key = sortKey.value currentPage.value = 1
const dir = sortDir.value === 'desc' ? -1 : 1 loadPage()
let sorted = [...constructeurs.value].sort((a, b) => { }, 300)
if (key === 'createdAt') {
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime()) watch(selectedCategoryId, () => {
} currentPage.value = 1
return dir * (a[key] || '').localeCompare(b[key] || '') loadPage()
})
if (selectedCategoryId.value) {
sorted = sorted.filter(item => (item.categories || []).some(cat => cat.id === selectedCategoryId.value))
}
if (!searchTerm.value) { return sorted }
const term = searchTerm.value.toLowerCase()
return sorted.filter((item) => {
const haystack = [item.name, item.email, ...rowPhones(item).map(t => t.numero)]
return haystack.some(value => value && String(value).toLowerCase().includes(term))
})
}) })
const debouncedSearch = debounce(async () => { watch([sortKey, sortDir], () => {
await searchConstructeurs(searchTerm.value) currentPage.value = 1
}, 300) loadPage()
})
const onPageChange = (page: number) => {
currentPage.value = page
loadPage()
}
const onPerPageChange = (value: number) => {
perPage.value = value
currentPage.value = 1
loadPage()
}
const formatDate = formatFrenchDate const formatDate = formatFrenchDate
@@ -386,7 +437,7 @@ const saveConstructeur = async () => {
saving.value = false saving.value = false
if (result.success) { if (result.success) {
closeModal() closeModal()
await searchConstructeurs(searchTerm.value) await loadPage()
} }
} }
@@ -397,6 +448,10 @@ const confirmDelete = async (constructeur) => {
const result = await deleteConstructeur(constructeur.id) const result = await deleteConstructeur(constructeur.id)
if (!result.success && result.error) { if (!result.success && result.error) {
showError(result.error) showError(result.error)
return
}
if (result.success) {
await loadPage()
} }
} }
@@ -408,7 +463,7 @@ const loadStats = async () => {
} }
onMounted(() => { onMounted(() => {
loadConstructeurs() loadPage()
loadCategories() loadCategories()
loadStats() loadStats()
}) })

View File

@@ -58,7 +58,26 @@
</option> </option>
</select> </select>
</div> </div>
<div class="form-control"> <div class="form-control md:w-52">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Trier par</span>
</label>
<select v-model="sortOrder" class="select select-bordered w-full">
<option value="name-asc">
Nom (A → Z)
</option>
<option value="name-desc">
Nom (Z → A)
</option>
<option value="date-desc">
Plus récentes
</option>
<option value="date-asc">
Plus anciennes
</option>
</select>
</div>
<div class="form-control md:w-80">
<label class="label"> <label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span> <span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
</label> </label>
@@ -66,13 +85,13 @@
<input <input
v-model="dateFrom" v-model="dateFrom"
type="date" type="date"
class="input input-bordered input-sm" class="input input-bordered w-full"
> >
<span class="text-xs text-base-content/50">à</span> <span class="text-xs text-base-content/50">à</span>
<input <input
v-model="dateTo" v-model="dateTo"
type="date" type="date"
class="input input-bordered input-sm" class="input input-bordered w-full"
> >
</div> </div>
</div> </div>
@@ -295,6 +314,7 @@ const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false) const showAddMachineModal = ref(false)
const searchTerm = ref('') const searchTerm = ref('')
const selectedSiteFilter = ref('') const selectedSiteFilter = ref('')
const sortOrder = ref('name-asc')
const dateFrom = ref('') const dateFrom = ref('')
const dateTo = ref('') const dateTo = ref('')
const collapsedSites = ref([]) const collapsedSites = ref([])
@@ -318,10 +338,33 @@ const machinesBySiteId = computed(() => {
return map return map
}) })
const sortMachines = (machineList) => {
const list = [...machineList]
switch (sortOrder.value) {
case 'name-desc':
return list.sort((a, b) =>
(b.name || '').localeCompare(a.name || '', 'fr', { sensitivity: 'base', numeric: true })
)
case 'date-desc':
return list.sort((a, b) =>
new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
)
case 'date-asc':
return list.sort((a, b) =>
new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
)
case 'name-asc':
default:
return list.sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr', { sensitivity: 'base', numeric: true })
)
}
}
const sitesWithMachines = computed(() => { const sitesWithMachines = computed(() => {
return sites.value.map((site) => ({ return sites.value.map((site) => ({
...site, ...site,
machines: machinesBySiteId.value.get(site.id) || [] machines: sortMachines(machinesBySiteId.value.get(site.id) || [])
})) }))
}) })

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
@@ -12,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait; use App\Entity\Trait\CuidEntityTrait;
use App\Filter\ConstructeurSearchFilter;
use App\Repository\ConstructeurRepository; use App\Repository\ConstructeurRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -26,6 +30,9 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)] #[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')] #[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(ConstructeurSearchFilter::class)]
#[ApiFilter(SearchFilter::class, properties: ['categories.id' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'email', 'createdAt'])]
#[ApiResource( #[ApiResource(
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.', description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
operations: [ operations: [
@@ -37,7 +44,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
], ],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200, paginationMaximumItemsPerPage: 2000,
normalizationContext: ['groups' => ['constructeur:read']], normalizationContext: ['groups' => ['constructeur:read']],
denormalizationContext: ['groups' => ['constructeur:write']] denormalizationContext: ['groups' => ['constructeur:write']]
)] )]

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\QueryBuilder;
/**
* Search filter pour Constructeur : LIKE insensible à la casse sur name, email
* + LEFT JOIN sur la collection telephones pour matcher aussi sur telephone.numero.
* Param query : ?search=...
*/
final class ConstructeurSearchFilter extends AbstractFilter
{
public function getDescription(string $resourceClass): array
{
return [
'search' => [
'property' => null,
'type' => 'string',
'required' => false,
'description' => 'Recherche dans le nom, l\'email et les numéros de téléphone du fournisseur.',
'openapi' => [
'allowEmptyValue' => true,
],
],
];
}
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if ('search' !== $property || !is_string($value) || '' === trim($value)) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
$telAlias = $queryNameGenerator->generateJoinAlias('phoneSearch');
$paramName = $queryNameGenerator->generateParameterName('search');
$likePattern = '%'.mb_strtolower(trim($value)).'%';
$em = $queryBuilder->getEntityManager();
$phoneSubQuery = $em->createQueryBuilder()
->select('1')
->from(ConstructeurTelephone::class, $telAlias)
->where(sprintf('%1$s.constructeur = %2$s', $telAlias, $alias))
->andWhere(sprintf('LOWER(%s.numero) LIKE :%s', $telAlias, $paramName))
->getDQL()
;
$queryBuilder
->andWhere(sprintf(
'LOWER(%1$s.name) LIKE :%2$s OR LOWER(%1$s.email) LIKE :%2$s OR EXISTS (%3$s)',
$alias,
$paramName,
$phoneSubQuery,
))
->setParameter($paramName, $likePattern)
;
}
}