feat(transport) : filtres checkbox, toggle « Voir les archivés », transporteurs dans Administration (ERP-164)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m9s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m36s

This commit is contained in:
2026-06-16 16:30:38 +02:00
parent 1ef4215ebf
commit 8046de76c6
7 changed files with 84 additions and 61 deletions
+13 -18
View File
@@ -78,23 +78,6 @@ return [
], ],
], ],
], ],
// Section "Transport" (M4, ERP-153) : pole logistique, porte le repertoire
// transporteurs. L'item est gate par `transport.carriers.view` ; la section
// disparait automatiquement (SidebarProvider) si le module `transport` est
// desactive ou si l'user n'a pas la permission (Compta / Usine).
[
'label' => 'sidebar.transport.section',
'icon' => 'mdi:truck-outline',
'items' => [
[
'label' => 'sidebar.transport.carriers',
'to' => '/carriers',
'icon' => 'mdi:truck-outline',
'module' => 'transport',
'permission' => 'transport.carriers.view',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration // Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log). // applicative (RBAC, users, sites, audit log).
// //
@@ -117,8 +100,20 @@ return [
// individuelles"), ajouter : 'permission' => 'core.admin.access'. // individuelles"), ajouter : 'permission' => 'core.admin.access'.
[ [
'label' => 'sidebar.administration.section', 'label' => 'sidebar.administration.section',
'icon' => 'mdi:cog-outline', 'icon' => 'mdi:file-settings-cog-outline',
'items' => [ 'items' => [
// Transport — Repertoire transporteurs (M4, ERP-164). Rattache a
// l'Administration (premier item) plutot qu'a une section dediee :
// referentiel global de configuration applicative, sans cloisonnement
// par site. Reste gate par sa propre permission `transport.carriers.view`
// (Admin / Bureau / Commerciale) et son module owner `transport`.
[
'label' => 'sidebar.transport.carriers',
'to' => '/carriers',
'icon' => 'mdi:truck-outline',
'module' => 'transport',
'permission' => 'transport.carriers.view',
],
[ [
'label' => 'sidebar.core.roles', 'label' => 'sidebar.core.roles',
'to' => '/admin/roles', 'to' => '/admin/roles',
+1 -1
View File
@@ -519,7 +519,7 @@
"search": "Recherche", "search": "Recherche",
"certification": "Certification", "certification": "Certification",
"status": "Statut", "status": "Statut",
"includeArchived": "Inclure les archivés", "archivedOnly": "Voir les archivés",
"apply": "Voir les résultats", "apply": "Voir les résultats",
"reset": "Réinitialiser" "reset": "Réinitialiser"
}, },
@@ -14,9 +14,10 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
* - l'enveloppe Hydra (member / totalItems) est consommee ; * - l'enveloppe Hydra (member / totalItems) est consommee ;
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4 * - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination) ; * renvoie un tableau plat sans pagination) ;
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye * - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les * tant que l'utilisateur ne coche pas le filtre (le back masque alors les
* archives — RG-4.04) ; le filtre est bien transmis une fois applique. * archives) ; le filtre « Voir les archivés » est bien transmis une fois
* applique (aligne sur Client / Fournisseur / Prestataire).
*/ */
describe('useCarriersRepository', () => { describe('useCarriersRepository', () => {
beforeEach(() => { beforeEach(() => {
@@ -58,27 +59,27 @@ describe('useCarriersRepository', () => {
expect(repo.totalItems.value).toBe(1) expect(repo.totalItems.value).toBe(1)
}) })
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => { it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useCarriersRepository() const repo = useCarriersRepository()
await repo.fetch() await repo.fetch()
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown> const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(query.includeArchived).toBeUndefined() expect(query.archivedOnly).toBeUndefined()
}) })
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => { it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useCarriersRepository() const repo = useCarriersRepository()
await repo.fetch() await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ includeArchived: true }) await repo.setFilters({ archivedOnly: true })
expect(repo.currentPage.value).toBe(1) expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown> const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.includeArchived).toBe(true) expect(query.archivedOnly).toBe(true)
}) })
it('transmet les certifications multiples + la recherche', async () => { it('transmet les certifications multiples + la recherche', async () => {
@@ -40,12 +40,13 @@ export interface Carrier {
* `GET /api/carriers` (spec-back § 4.1). Pilotes par la page via `setFilters` : * `GET /api/carriers` (spec-back § 4.1). Pilotes par la page via `setFilters` :
* - `search` : recherche fuzzy sur le nom ; * - `search` : recherche fuzzy sur le nom ;
* - `certificationType[]` : multi-valeurs (OR cote back) ; * - `certificationType[]` : multi-valeurs (OR cote back) ;
* - `includeArchived` : reintegre les archives (masquees par defaut). * - `archivedOnly` : n'affiche QUE les archives (toggle « Voir les archivés »,
* aligne sur les autres repertoires M1/M2/M3).
*/ */
export interface CarrierFilters { export interface CarrierFilters {
search?: string search?: string
'certificationType[]'?: string[] 'certificationType[]'?: string[]
includeArchived?: boolean archivedOnly?: boolean
} }
/** /**
@@ -56,8 +57,9 @@ export interface CarrierFilters {
* *
* Les filtres (recherche, certifications, archives) sont pilotes par la page via * Les filtres (recherche, certifications, archives) sont pilotes par la page via
* `setFilters` du composable partage — la remise en page 1 est garantie. Par * `setFilters` du composable partage — la remise en page 1 est garantie. Par
* defaut AUCUN `includeArchived` n'est envoye : le back masque alors les archives * defaut AUCUN `archivedOnly` n'est envoye : le back masque alors les archives
* (RG-4.04, § 2.4). Cocher « Inclure les archivés » envoie `includeArchived=true`. * (§ 2.4). Cocher « Voir les archivés » envoie `archivedOnly=true` (seules les
* archives sont listees, aligne sur Client / Fournisseur / Prestataire).
* *
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau * Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de * est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
@@ -95,7 +95,6 @@ const CheckboxStub = defineComponent({
}, },
}) })
const SelectCheckboxStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'select-checkbox' }) } })
const InputTextStub = defineComponent({ setup() { return () => h('input') } }) const InputTextStub = defineComponent({ setup() { return () => h('input') } })
function mountPage() { function mountPage() {
@@ -109,7 +108,6 @@ function mountPage() {
MalioAccordion: SlotStub, MalioAccordion: SlotStub,
MalioAccordionItem: SlotStub, MalioAccordionItem: SlotStub,
MalioInputText: InputTextStub, MalioInputText: InputTextStub,
MalioSelectCheckbox: SelectCheckboxStub,
MalioCheckbox: CheckboxStub, MalioCheckbox: CheckboxStub,
}, },
}, },
@@ -165,27 +163,42 @@ describe('Répertoire transporteurs (page /carriers)', () => {
) )
}) })
it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => { it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
// Coche « Inclure les archivés » puis applique les filtres. // Coche « Voir les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click') await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith( expect(mockSetFilters).toHaveBeenLastCalledWith(
{ includeArchived: true }, { archivedOnly: true },
{ replace: true }, { replace: true },
) )
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee. // Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
expect(mockPush).not.toHaveBeenCalled() expect(mockPush).not.toHaveBeenCalled()
}) })
it('repercute les certifications cochees dans setFilters (filtre multi)', async () => {
const wrapper = mountPage()
await flushPromises()
// Coche deux certifications via les cases a cocher (pattern repertoire clients).
await wrapper.find('input[data-id="filter-certification-QUALIMAT"]').setValue(true)
await wrapper.find('input[data-id="filter-certification-AUTRE"]').setValue(true)
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ 'certificationType[]': ['QUALIMAT', 'AUTRE'] },
{ replace: true },
)
})
it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => { it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click') await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif). // Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
@@ -95,24 +95,28 @@
/> />
</MalioAccordionItem> </MalioAccordionItem>
<!-- Certification : select multi (cases a cocher). Valeur = code enum. --> <!-- Certification : cases a cocher (multi). Valeur = code enum.
Meme pattern que le filtre Categories du repertoire clients. -->
<MalioAccordionItem :title="t('transport.carriers.filters.certification')" value="certification"> <MalioAccordionItem :title="t('transport.carriers.filters.certification')" value="certification">
<MalioSelectCheckbox <div class="flex flex-col">
:model-value="draftCertificationTypes" <MalioCheckbox
:options="certificationOptions" v-for="opt in certificationOptions"
:label="t('transport.carriers.filters.certification')" :id="`filter-certification-${opt.value}`"
:display-tag="true" :key="opt.value"
@update:model-value="(v: (string | number)[]) => draftCertificationTypes = v.map(String)" :label="opt.label"
/> :model-value="draftCertificationTypes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleCertification(opt.value, val)"
/>
</div>
</MalioAccordionItem> </MalioAccordionItem>
<!-- Statut : inclure les archives (sinon actifs uniquement). --> <!-- Statut : voir uniquement les archives (sinon actifs uniquement). -->
<MalioAccordionItem :title="t('transport.carriers.filters.status')" value="status"> <MalioAccordionItem :title="t('transport.carriers.filters.status')" value="status">
<MalioCheckbox <MalioCheckbox
id="filter-include-archived" id="filter-archived-only"
:label="t('transport.carriers.filters.includeArchived')" :label="t('transport.carriers.filters.archivedOnly')"
:model-value="draftIncludeArchived" :model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftIncludeArchived = val" @update:model-value="(val: boolean) => draftArchivedOnly = val"
/> />
</MalioAccordionItem> </MalioAccordionItem>
</MalioAccordion> </MalioAccordion>
@@ -263,17 +267,17 @@ const filterDrawerOpen = ref(false)
const draftSearch = ref('') const draftSearch = ref('')
const draftCertificationTypes = ref<string[]>([]) const draftCertificationTypes = ref<string[]>([])
const draftIncludeArchived = ref(false) const draftArchivedOnly = ref(false)
const appliedSearch = ref('') const appliedSearch = ref('')
const appliedCertificationTypes = ref<string[]>([]) const appliedCertificationTypes = ref<string[]>([])
const appliedIncludeArchived = ref(false) const appliedArchivedOnly = ref(false)
const activeFilterCount = computed(() => { const activeFilterCount = computed(() => {
let count = 0 let count = 0
if (appliedSearch.value.trim() !== '') count++ if (appliedSearch.value.trim() !== '') count++
if (appliedCertificationTypes.value.length > 0) count++ if (appliedCertificationTypes.value.length > 0) count++
if (appliedIncludeArchived.value) count++ if (appliedArchivedOnly.value) count++
return count return count
}) })
@@ -287,10 +291,17 @@ const filterButtonLabel = computed(() => {
function openFilters(): void { function openFilters(): void {
draftSearch.value = appliedSearch.value draftSearch.value = appliedSearch.value
draftCertificationTypes.value = [...appliedCertificationTypes.value] draftCertificationTypes.value = [...appliedCertificationTypes.value]
draftIncludeArchived.value = appliedIncludeArchived.value draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true filterDrawerOpen.value = true
} }
/** Coche / decoche une certification dans le brouillon (filtre multi). */
function toggleCertification(code: string, selected: boolean): void {
draftCertificationTypes.value = selected
? [...draftCertificationTypes.value, code]
: draftCertificationTypes.value.filter(c => c !== code)
}
/** /**
* Construit le payload de filtres serveur a partir de l'etat applique. Cle * Construit le payload de filtres serveur a partir de l'etat applique. Cle
* `certificationType[]` pour que PHP la parse en tableau (OR cote back). Les * `certificationType[]` pour que PHP la parse en tableau (OR cote back). Les
@@ -300,7 +311,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
const payload: Record<string, string | string[] | boolean> = {} const payload: Record<string, string | string[] | boolean> = {}
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim() if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCertificationTypes.value.length > 0) payload['certificationType[]'] = [...appliedCertificationTypes.value] if (appliedCertificationTypes.value.length > 0) payload['certificationType[]'] = [...appliedCertificationTypes.value]
if (appliedIncludeArchived.value) payload.includeArchived = true if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload return payload
} }
@@ -309,7 +320,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
function applyFilters(): void { function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim() appliedSearch.value = draftSearch.value.trim()
appliedCertificationTypes.value = [...draftCertificationTypes.value] appliedCertificationTypes.value = [...draftCertificationTypes.value]
appliedIncludeArchived.value = draftIncludeArchived.value appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true }) setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false filterDrawerOpen.value = false
@@ -320,11 +331,11 @@ function applyFilters(): void {
function resetFilters(): void { function resetFilters(): void {
draftSearch.value = '' draftSearch.value = ''
draftCertificationTypes.value = [] draftCertificationTypes.value = []
draftIncludeArchived.value = false draftArchivedOnly.value = false
appliedSearch.value = '' appliedSearch.value = ''
appliedCertificationTypes.value = [] appliedCertificationTypes.value = []
appliedIncludeArchived.value = false appliedArchivedOnly.value = false
setFilters({}, { replace: true }) setFilters({}, { replace: true })
} }
+4 -3
View File
@@ -95,10 +95,11 @@ export const personas: Record<PersonaKey, Persona> = {
'technique.providers.accounting.view', 'technique.providers.accounting.view',
'technique.providers.accounting.manage', 'technique.providers.accounting.manage',
'technique.providers.archive', 'technique.providers.archive',
// Transport — Repertoire transporteurs (M4, ERP-153). Meme logique : // Transport — Repertoire transporteurs (M4, ERP-164). Meme logique :
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE // mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
// n°7). transport.carriers.view n'ajoute pas de lien dans la section // n°7). L'item transporteurs vit desormais dans la section Administration
// Administration, donc expectedAdminLinks reste inchange. // (1er item, ERP-164) mais sur la route `/carriers` (hors `/admin/<slug>`),
// donc il n'entre pas dans ALL_ADMIN_LINKS : expectedAdminLinks reste inchange.
'transport.carriers.view', 'transport.carriers.view',
'transport.carriers.manage', 'transport.carriers.manage',
'transport.carriers.archive', 'transport.carriers.archive',