diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 33d5aea..174a84a 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -366,6 +366,34 @@ } } }, + "technique": { + "providers": { + "title": "Répertoire prestataires", + "add": "Ajouter", + "export": "Exporter", + "empty": "Aucun prestataire pour l'instant.", + "column": { + "companyName": "Nom", + "categories": "Catégories", + "sites": "Site", + "lastActivity": "Dernière activité" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "categories": "Catégories", + "sites": "Sites", + "status": "Statut", + "includeArchived": "Inclure les archivés", + "apply": "Voir les résultats", + "reset": "Réinitialiser" + }, + "toast": { + "error": "Une erreur est survenue. Réessayez.", + "exportError": "L'export du répertoire prestataires a échoué. Réessayez." + } + } + }, "auth": { "login": "Connexion", "logout": "Deconnexion", diff --git a/frontend/modules/technique/composables/__tests__/useProvidersRepository.test.ts b/frontend/modules/technique/composables/__tests__/useProvidersRepository.test.ts new file mode 100644 index 0000000..2bbf646 --- /dev/null +++ b/frontend/modules/technique/composables/__tests__/useProvidersRepository.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useProvidersRepository, type Provider } from '../useProvidersRepository' + +const mockApiGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ get: mockApiGet })) + +/** + * Tests du repertoire prestataires (ERP-140). + * + * `useProvidersRepository` est une fine enveloppe de `usePaginatedList` + * sur `/providers`. Les invariants generiques de pagination sont deja couverts + * par `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire : + * - la ressource ciblee est bien `/providers` + * - 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 + * tant que l'utilisateur ne coche pas le filtre (le back masque alors les + * archives) ; le filtre `includeArchived` est bien transmis une fois applique. + */ +describe('useProvidersRepository', () => { + beforeEach(() => { + mockApiGet.mockReset() + }) + + /** Une page de prestataires Hydra, avec categories[] et sites[] embarques. */ + const PAGE: Provider[] = [ + { + id: 1, + companyName: 'ACME MAINTENANCE', + categories: [{ code: 'MAINTENANCE_INDUSTRIELLE', name: 'Maintenance industrielle' }], + sites: [{ id: 4, name: 'Chatellerault', color: '#056CF2' }], + updatedAt: '2026-06-15T08:12:01+02:00', + isArchived: false, + }, + ] + + it('cible /providers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => { + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + const repo = useProvidersRepository() + + await repo.fetch() + + expect(mockApiGet).toHaveBeenCalledTimes(1) + const [url, query, opts] = mockApiGet.mock.calls[0] + expect(url).toBe('/providers') + expect(query).toMatchObject({ page: 1, itemsPerPage: 10 }) + expect(opts).toMatchObject({ + toast: false, + headers: { Accept: 'application/ld+json' }, + }) + expect(repo.items.value).toEqual(PAGE) + expect(repo.totalItems.value).toBe(1) + }) + + it('exclut les archives par defaut : aucun includeArchived 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 + expect(query.includeArchived).toBeUndefined() + }) + + it('transmet includeArchived 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 }) + + expect(repo.currentPage.value).toBe(1) + const query = mockApiGet.mock.calls.at(-1)?.[1] as Record + expect(query.includeArchived).toBe(true) + }) +}) diff --git a/frontend/modules/technique/composables/useProvidersRepository.ts b/frontend/modules/technique/composables/useProvidersRepository.ts new file mode 100644 index 0000000..de3ff19 --- /dev/null +++ b/frontend/modules/technique/composables/useProvidersRepository.ts @@ -0,0 +1,62 @@ +import { usePaginatedList } from '~/shared/composables/usePaginatedList' + +/** + * Site Starseed rattache DIRECTEMENT au prestataire (M2M `provider_site`, + * RG-3.03), tel qu'embarque en LISTE (groupe site:read) pour la colonne « Site » + * du Repertoire (badges colores). + * + * Difference M3 vs M2 : au M2 les sites venaient de l'agregat dedoublonne des + * adresses (`Supplier::getSites()`) ; ici c'est une relation directe portee par + * le formulaire principal (cf. spec-back M3 § 2.12). + */ +export interface ProviderSite { + id: number + name: string + color: string +} + +/** + * Categorie (type PRESTATAIRE) rattachee au prestataire, embarquee en LISTE + * (groupe category:read). La colonne « Catégories » affiche le `name` (cohérence + * M1/M2 — libellé = `name`, pas `code`). + */ +export interface ProviderCategory { + code: string + name: string +} + +/** + * Vue MINIMALE d'un prestataire pour le Repertoire (datatable). Volontairement + * partielle : seuls les champs des colonnes + l'id (navigation) sont types ici. + * Le detail complet (onglets) est hors perimetre de cet ecran (ERP-140). + */ +export interface Provider { + id: number + companyName: string + categories: ProviderCategory[] + sites: ProviderSite[] + /** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */ + updatedAt: string | null + isArchived: boolean +} + +/** + * Repertoire prestataires (ERP-140) — simple enveloppe de `usePaginatedList` + * 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). + * + * Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en + * fonction de l'utilisateur — rien a filtrer cote front. + * + * 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 + * `usePaginatedList`. Aucun reset au logout a gerer. + */ +export function useProvidersRepository() { + return usePaginatedList({ url: '/providers' }) +} diff --git a/frontend/modules/technique/pages/providers/index.vue b/frontend/modules/technique/pages/providers/index.vue new file mode 100644 index 0000000..cfedde4 --- /dev/null +++ b/frontend/modules/technique/pages/providers/index.vue @@ -0,0 +1,438 @@ + + + + +