Compare commits

..

2 Commits

Author SHA1 Message Date
gitea-actions 39071cbec0 chore: bump version to v0.1.127
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m10s
2026-06-16 06:12:30 +00:00
tristan b82acdac01 fix(front) : aligner le filtre archives des répertoires fournisseurs et prestataires sur client (ERP-173) (#110)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte (ERP-173)

Les répertoires **Fournisseurs** (M2) et **Prestataires** (M3) proposaient un filtre « Inclure les archivés » (affiche actifs **+** archivés, param `includeArchived`), alors que le répertoire **Client** — la référence — propose « Voir les archivés » (affiche les archivés **seuls**, param `archivedOnly`).

## Diagnostic

Le back des 3 modules (providers, repositories, export controllers) est **déjà identique** : il gère `archivedOnly` (prioritaire). Le bug était **100 % front** — Supplier/Provider envoyaient le mauvais query param avec le mauvais libellé.

## Changement (front uniquement)

- Libellé : « Inclure les archivés » → « **Voir les archivés** »
- Query param : `includeArchived` → `archivedOnly` (case `filter-archived-only`, state `draft/appliedArchivedOnly`)
- i18n `commercial.suppliers.filters` + `technique.providers.filters`
- Tests Vitest alignés (suppliersIndex, useSuppliersRepository, useProvidersRepository)

Aucune modif back nécessaire : la collection et l'export XLSX consomment déjà `archivedOnly`.

## Vérifications

- `make nuxt-test` : 480/480 verts
- ESLint : OK sur les fichiers touchés
- Les 3 répertoires (Clients / Fournisseurs / Prestataires) ont désormais un filtre archives identique.

Reviewed-on: #110
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 06:12:19 +00:00
14 changed files with 58 additions and 158 deletions
-17
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
// applicative (RBAC, users, sites, audit log).
//
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.126'
app.version: '0.1.127'
+2 -6
View File
@@ -34,10 +34,6 @@
"section": "Technique",
"providers": "Répertoire prestataires"
},
"transport": {
"section": "Transport",
"carriers": "Répertoire transporteurs"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
@@ -74,7 +70,7 @@
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"archivedOnly": "Voir les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
@@ -388,7 +384,7 @@
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"archivedOnly": "Voir les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
@@ -51,7 +51,7 @@ describe('useSuppliersRepository', () => {
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
archivedOnly: true,
},
{ replace: true },
)
@@ -63,7 +63,7 @@ describe('useSuppliersRepository', () => {
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
archivedOnly: true,
page: 1,
itemsPerPage: 10,
},
@@ -73,7 +73,7 @@ describe('useSuppliersRepository', () => {
it('repasse a une query propre apres reinitialisation des filtres', async () => {
const repo = useSuppliersRepository()
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
await repo.setFilters({}, { replace: true })
expect(mockGet).toHaveBeenLastCalledWith(
@@ -172,16 +172,16 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
)
})
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()
await flushPromises()
// Coche « Inclure les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
// Coche « Voir les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ includeArchived: true },
{ archivedOnly: true },
{ replace: true },
)
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
@@ -192,7 +192,7 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
const wrapper = mountPage()
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="commercial.suppliers.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
@@ -128,13 +128,13 @@
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('commercial.suppliers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
id="filter-archived-only"
:label="t('commercial.suppliers.filters.archivedOnly')"
:model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftArchivedOnly = val"
/>
</MalioAccordionItem>
</MalioAccordion>
@@ -254,12 +254,12 @@ const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const draftArchivedOnly = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
const appliedArchivedOnly = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
@@ -270,7 +270,7 @@ const activeFilterCount = computed(() => {
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
if (appliedArchivedOnly.value) count++
return count
})
@@ -285,7 +285,7 @@ function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true
}
@@ -311,7 +311,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload
}
@@ -321,7 +321,7 @@ function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
@@ -333,12 +333,12 @@ function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
draftArchivedOnly.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
appliedArchivedOnly.value = false
setFilters({}, { replace: true })
}
@@ -14,9 +14,9 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
* - l'enveloppe Hydra (member / totalItems) est consommee
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* 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
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
* archives) ; le filtre `archivedOnly` est bien transmis une fois applique.
*/
describe('useProvidersRepository', () => {
beforeEach(() => {
@@ -53,26 +53,26 @@ describe('useProvidersRepository', () => {
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 })
const repo = useProvidersRepository()
await repo.fetch()
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 })
const repo = useProvidersRepository()
await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ includeArchived: true })
await repo.setFilters({ archivedOnly: true })
expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.includeArchived).toBe(true)
expect(query.archivedOnly).toBe(true)
})
})
@@ -45,10 +45,11 @@ export interface Provider {
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
*
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
* par la page via `setFilters` du composable partage — la remise en page 1 est
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
* via `setFilters` du composable partage — la remise en page 1 est garantie. Par
* defaut, aucun `archivedOnly` n'est envoye : le back masque donc les prestataires
* archives (exclusion par defaut, spec-back § 2.11). Cocher « Voir les archivés »
* envoie `archivedOnly=true` → seules les archives sont listees (aligne sur Client).
*
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
* fonction de l'utilisateur — rien a filtrer cote front.
@@ -129,13 +129,13 @@
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('technique.providers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
id="filter-archived-only"
:label="t('technique.providers.filters.archivedOnly')"
:model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftArchivedOnly = val"
/>
</MalioAccordionItem>
</MalioAccordion>
@@ -258,12 +258,12 @@ const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const draftArchivedOnly = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
const appliedArchivedOnly = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
@@ -274,7 +274,7 @@ const activeFilterCount = computed(() => {
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
if (appliedArchivedOnly.value) count++
return count
})
@@ -289,7 +289,7 @@ function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true
}
@@ -315,7 +315,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload
}
@@ -325,7 +325,7 @@ function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
@@ -337,12 +337,12 @@ function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
draftArchivedOnly.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
appliedArchivedOnly.value = false
setFilters({}, { replace: true })
}
-7
View File
@@ -95,13 +95,6 @@ export const personas: Record<PersonaKey, Persona> = {
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
// Transport — Repertoire transporteurs (M4, ERP-153). Meme logique :
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
// n°7). transport.carriers.view n'ajoute pas de lien dans la section
// Administration, donc expectedAdminLinks reste inchange.
'transport.carriers.view',
'transport.carriers.manage',
'transport.carriers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
@@ -51,9 +51,9 @@ final class RbacSeeder
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
* `commercial.clients.archive`, `commercial.suppliers.archive`,
* `technique.providers.archive` et `transport.carriers.archive` ne sont
* attaches a aucun role metier — admin seul).
* `commercial.clients.archive`, `commercial.suppliers.archive` et
* `technique.providers.archive` ne sont attaches a aucun role metier —
* admin seul).
*
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
@@ -77,9 +77,6 @@ final class RbacSeeder
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
'technique.providers.view',
'technique.providers.manage',
// Transporteurs (M4 § 5.2, ERP-153) : view + manage (PAS archive -> admin seul).
'transport.carriers.view',
'transport.carriers.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
@@ -123,9 +120,6 @@ final class RbacSeeder
// (onglet Comptabilite masque/filtre pour la Commerciale).
'technique.providers.view',
'technique.providers.manage',
// Transporteurs (M4 § 5.2, ERP-153) : view seul (consultation « Tout »,
// ni manage ni archive pour la Commerciale).
'transport.carriers.view',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
@@ -212,11 +212,6 @@ final class SeedE2ECommand extends Command
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
// Transport — Repertoire transporteurs (M4, ERP-153). Meme
// logique : mappe sur le persona "tout". Miroir de personas.ts.
'transport.carriers.view',
'transport.carriers.manage',
'transport.carriers.archive',
],
],
[
+6 -11
View File
@@ -13,22 +13,17 @@ final class TransportModule
/**
* Liste declarative des permissions RBAC exposees par le module Transport.
*
* Socle du repertoire transporteurs (M4 § 5.1, ERP-153) :
* - `view` : consultation de la liste / fiche transporteur ;
* - `manage` : creation / modification (hors archivage) ;
* - `archive` : archivage / restauration (admin seul, cf. matrice § 5.2).
* Vide a ce stade : le module ne porte que des referentiels externes
* synchronises par commandes console (codes IDTF - ERP-149, transporteurs
* QUALIMAT - ERP-39), sans ecran ni action protegee. Les permissions seront
* ajoutees quand une page de consultation sera exposee.
*
* Consommee par `app:sync-permissions`. Matrice role -> permissions dans
* `RbacSeeder::MATRIX` (§ 5.2).
* Consommee par `app:sync-permissions` (un tableau vide est valide).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'transport.carriers.view', 'label' => 'Voir les transporteurs'],
['code' => 'transport.carriers.manage', 'label' => 'Créer / modifier les transporteurs'],
['code' => 'transport.carriers.archive', 'label' => 'Archiver / restaurer un transporteur'],
];
return [];
}
}
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport;
use App\Module\Transport\TransportModule;
use PHPUnit\Framework\TestCase;
/**
* Tests structurels du module Transport (M4) : identite et contrat
* `permissions()` (socle RBAC, ERP-153).
*
* @internal
*/
final class TransportModuleTest extends TestCase
{
public function testModuleIdentity(): void
{
self::assertSame('transport', TransportModule::ID);
self::assertSame('Transport', TransportModule::LABEL);
self::assertFalse(TransportModule::REQUIRED);
}
public function testPermissionsSetContainsExactlyThreeCodes(): void
{
// Garde-fou : le jeu de permissions du module est fige par ce test. Si
// quelqu'un ajoute / retire une permission sans ajuster la spec (§ 5.1)
// ni la matrice RBAC (§ 5.2), le test casse explicitement.
$codes = array_column(TransportModule::permissions(), 'code');
sort($codes);
self::assertSame(
[
'transport.carriers.archive',
'transport.carriers.manage',
'transport.carriers.view',
],
$codes,
);
}
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
{
// Convention de nommage `module.resource[.sub].action` : le prefixe doit
// correspondre exactement a l'ID du module (verifie aussi par
// app:sync-permissions).
foreach (TransportModule::permissions() as $permission) {
self::assertStringStartsWith(
TransportModule::ID.'.',
$permission['code'],
'Chaque code de permission doit etre prefixe par l\'ID du module.',
);
self::assertNotSame('', $permission['label'], 'Chaque permission doit porter un label.');
}
}
}