Compare commits

..

4 Commits

Author SHA1 Message Date
gitea-actions a6f01400ba chore: bump version to v0.1.116
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 40s
2026-06-15 09:02:10 +00:00
tristan d0e9f48983 feat(front) : page ajout prestataire + formulaire principal (ERP-141) (#103)
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-140 (#102).

## Périmètre ERP-141
Écran `/providers/new` — création par onglets + formulaire principal (POST).

- **Page** `modules/technique/pages/providers/new.vue` : en-tête + retour, formulaire principal (Nom, Catégorie, Site), barre d'onglets **Contact · Adresse · Comptabilité** (pas d'onglet Information ; Rapports/Échanges absents en création). Contenu des onglets = placeholders « À venir » (ERP-142→144).
- **`useProviderForm()`** : POST principal (groupe `provider:write:main`, IRIs catégories/sites), pré-check front RG-3.03 (≥1 site) / RG-3.09 (≥1 catégorie), 409 doublon (RG-3.10) inline, 422 mapping par champ via `useFormErrors`, orchestration des onglets (verrouillage + bascule auto sur Contact au succès), `patchProvider` (PATCH partiel mode strict pour les onglets à venir).
- **`useProviderReferentials()`** : catégories type PRESTATAIRE + sites (`?pagination=false`, Hydra).
- i18n `technique.providers.form/tab/toast`.

## Conformité
- `useApi()` uniquement, composants `Malio*`, aucun texte FR en dur, bouton « Valider » toujours actif + erreurs sous les champs (ERP-101).

## Vérifications
- Vitest : 402/402 (dont 9 nouveaux tests `useProviderForm`).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : page rendue, catégories filtrées PRESTATAIRE, sélecteur site, onglets désactivés avant validation, erreurs inline RG-3.03/3.09.

Reviewed-on: #103
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 08:59:39 +00:00
gitea-actions c1206fa29c chore: bump version to v0.1.115
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-15 08:51:28 +00:00
tristan 090ea5eb49 feat(front) : page repertoire prestataires (ERP-140) (#102)
Auto Tag Develop / tag (push) Successful in 9s
Page d'entree du pole Technique : repertoire prestataires (route /providers).

## Perimetre (ERP-140)
- Page `modules/technique/pages/providers/index.vue` (route /providers, titre i18n technique.providers.title).
- `MalioDataTable` branche sur `usePaginatedList<Provider>({ url: '/providers' })` : colonnes Nom / Categories / Site (badges) / Derniere activite (updatedAt, format JJ-MM-AAAA).
- Clic ligne -> /providers/{id} ; bouton + Ajouter -> /providers/new (gate technique.providers.manage).
- Drawer Filtres : recherche, categorie (type PRESTATAIRE), site, inclure archives. Etat 100% local (jamais dans l'URL).
- Bouton Exporter -> /api/providers/export.xlsx (memes filtres).
- Pagination standard 10/25/50.
- Composable `useProvidersRepository` + cles i18n `technique.providers.*`.

## Garde-fous
- `useApi()` uniquement, composants `Malio*`, pas de `<table>` brut, aucun texte FR en dur.
- Cloisonnement par site laisse au back.

## Tests
- `make nuxt-test` : 393/393 verts (dont 3 nouveaux sur useProvidersRepository : ciblage /providers, enveloppe Hydra, exclusion archives par defaut).
- ESLint clean.
- Note : `nuxi typecheck` non concluant dans l'env (develop produit deja ~303 erreurs d'auto-imports non resolus, independamment de cette branche). La page et le composable sont type-clean.

Reviewed-on: #102
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 08:51:19 +00:00
11 changed files with 1281 additions and 2 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ return [
// `technique` est desactive ou si l'user n'a pas la permission. // `technique` est desactive ou si l'user n'a pas la permission.
[ [
'label' => 'sidebar.technique.section', 'label' => 'sidebar.technique.section',
'icon' => 'mdi:wrench-outline', 'icon' => 'mdi:account-convert-outline',
'items' => [ 'items' => [
[ [
'label' => 'sidebar.technique.providers', 'label' => 'sidebar.technique.providers',
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.114' app.version: '0.1.116'
+49
View File
@@ -366,6 +366,55 @@
} }
} }
}, },
"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"
},
"tab": {
"contact": "Contact",
"address": "Adresse",
"accounting": "Comptabilité"
},
"form": {
"title": "Ajouter un prestataire",
"back": "Précédent",
"submit": "Valider",
"duplicateCompany": "Un prestataire portant ce nom de société existe déjà.",
"main": {
"companyName": "Nom du prestataire (Entreprise)",
"categories": "Catégorie",
"sites": "Site"
},
"errors": {
"siteRequired": "Sélectionnez au moins un site.",
"categoryRequired": "Sélectionnez au moins une catégorie."
}
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
"createSuccess": "Prestataire créé avec succès"
}
}
},
"auth": { "auth": {
"login": "Connexion", "login": "Connexion",
"logout": "Deconnexion", "logout": "Deconnexion",
@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141).
*
* `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et
* l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la
* creation :
* - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie
* -> POST bloque, erreurs inline, aucun appel reseau.
* - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json
* + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact +
* reaffichage du nom normalise.
* - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName.
* - 422 -> mapping inline par champ (propertyPath).
* - Onglets : « Comptabilite » present uniquement avec accounting.view ;
* completeTab deverrouille/avance et signale le dernier onglet.
*/
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite).
const permState = vi.hoisted(() => ({ accountingView: false }))
vi.stubGlobal('useApi', () => ({
get: vi.fn(),
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useToast', () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}))
vi.stubGlobal('usePermissions', () => ({
can: (perm: string) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true,
}))
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
const SITE_86 = '/api/sites/1'
const CAT_MAINT = '/api/categories/7'
describe('useProviderForm', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
})
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
expect(form.mainLocked.value).toBe(false)
})
it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
form.main.siteIris = [SITE_86]
await form.submitMain()
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBeUndefined()
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
})
it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => {
mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' })
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(true)
expect(mockPost).toHaveBeenCalledTimes(1)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers')
expect(body).toEqual({
companyName: 'Maintenance Pro',
categories: [CAT_MAINT],
sites: [SITE_86],
})
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.providerId.value).toBe(42)
// RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur.
expect(form.main.companyName).toBe('MAINTENANCE PRO')
expect(form.mainLocked.value).toBe(true)
expect(form.activeTab.value).toBe('contact')
expect(form.unlockedIndex.value).toBe(0)
})
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
const form = useProviderForm()
form.main.companyName = ' '
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
expect(body).not.toHaveProperty('companyName')
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
})
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
const form = useProviderForm()
form.main.companyName = 'Doublon'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
expect(form.mainLocked.value).toBe(false)
})
it('422 : mappe les violations serveur inline par champ', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] },
},
})
const form = useProviderForm()
form.main.companyName = 'X'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.')
})
it('onglet Comptabilite : absent sans accounting.view, present avec', () => {
expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address'])
expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting'])
permState.accountingView = true
const form = useProviderForm()
expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting'])
})
it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => {
const form = useProviderForm()
// Contact -> Adresse (pas le dernier).
expect(form.completeTab('contact')).toBe(false)
expect(form.isValidated('contact')).toBe(true)
expect(form.activeTab.value).toBe('address')
expect(form.unlockedIndex.value).toBe(1)
// Adresse = dernier onglet remplissable (sans accounting.view) -> true.
expect(form.completeTab('address')).toBe(true)
expect(form.isValidated('address')).toBe(true)
})
it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => {
const form = useProviderForm()
await form.patchProvider({ siren: '123456789' })
expect(mockPatch).not.toHaveBeenCalled()
mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' })
form.main.companyName = 'Acme'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
await form.patchProvider({ siren: '123456789' })
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
})
})
@@ -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<Provider>`
* 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<string, unknown>
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<string, unknown>
expect(query.includeArchived).toBe(true)
})
})
@@ -0,0 +1,207 @@
import { computed, reactive, ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import {
emptyProviderMain,
type ProviderMainDraft,
type ProviderMainResponse,
} from '~/modules/technique/types/providerForm'
/**
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
* miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en
* composable.
*
* Particularites M3 (cf. spec-front § « Ecran Ajouter ») :
* - PAS d'onglet « Information » : le formulaire principal est minimal (Nom +
* Categorie + Site).
* - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe
* `provider.sites`).
* - Creation incrementale par onglets (Contact · Adresse · Comptabilite) :
* POST principal puis PATCH partiels par groupe de serialisation
* (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets
* arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et
* l'orchestration des onglets.
*
* Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL.
*/
/**
* Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ;
* « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification.
* L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir
* (`technique.providers.accounting.view` — Admin, Compta).
*/
export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] {
return canAccountingView
? ['contact', 'address', 'accounting']
: ['contact', 'address']
}
export function useProviderForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
const { can } = usePermissions()
// Erreurs de validation par champ (ERP-101) du formulaire principal.
const mainErrors = useFormErrors()
// ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ──────────────────────────────────────────────────
const main = reactive<ProviderMainDraft>(emptyProviderMain())
// ── Onglets : ordre + gating progressif ───────────────────────────────────
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
const unlockedIndex = ref(-1)
const activeTab = ref<string>('contact')
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
function isValidated(key: string): boolean {
return validated[key] === true
}
function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/**
* Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09
* (>= 1 categorie). Pose les erreurs inline et retourne false si invalide.
* Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un
* aller-retour inutile et porte la garantie RG-3.03 cote front.
*/
function validateMainFront(): boolean {
let valid = true
if (main.siteIris.length === 0) {
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
valid = false
}
if (main.categoryIris.length === 0) {
mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired'))
valid = false
}
return valid
}
/**
* Payload du POST principal (groupe `provider:write:main`). `companyName` est
* omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur
* le champ plutot qu'une erreur de type. Les relations M2M partent en IRI.
*/
function buildMainPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = {
categories: [...main.categoryIris],
sites: [...main.siteIris],
}
if (main.companyName?.trim()) {
payload.companyName = main.companyName
}
return payload
}
/**
* POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09,
* puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er
* onglet et bascule sur « Contact ». Retourne true si cree, false sinon.
*/
async function submitMain(): Promise<boolean> {
if (mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
const created = await api.post<ProviderMainResponse>('/providers', buildMainPayload(), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
providerId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11).
main.companyName = created.companyName ?? main.companyName
mainLocked.value = true
unlockedIndex.value = 0
activeTab.value = tabKeys.value[0] ?? 'contact'
toast.success({ title: t('technique.providers.toast.createSuccess') })
return true
}
catch (error) {
// 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ;
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('technique.providers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('technique.providers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
}
return false
}
finally {
mainSubmitting.value = false
}
}
/**
* PATCH partiel du prestataire (mode strict : un seul groupe de serialisation
* par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires
* (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources
* (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas.
*/
async function patchProvider(payload: Record<string, unknown>): Promise<void> {
if (providerId.value === null) return
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
}
/**
* Marque un onglet valide (passe en lecture seule), deverrouille et avance a
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
* (creation terminee), false sinon.
*/
function completeTab(key: string): boolean {
validated[key] = true
const index = tabIndex(key)
const next = tabKeys.value[index + 1]
if (next === undefined) {
return true
}
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
activeTab.value = next
return false
}
return {
// etat
main,
providerId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
// onglets
canAccountingView,
tabKeys,
activeTab,
unlockedIndex,
validated,
isValidated,
// actions
validateMainFront,
buildMainPayload,
submitMain,
patchProvider,
completeTab,
}
}
@@ -0,0 +1,85 @@
import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects du formulaire
* principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) :
* categories (type PRESTATAIRE) et sites (86 / 17 / 82).
*
* Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire
* principal) seuls categories + sites sont necessaires. Les referentiels
* comptables (modes de TVA, delais/types de reglement, banques) seront charges
* par l'onglet Comptabilite (ERP-144).
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques entrees), avec l'en-tete
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
* quelle dans le payload POST (relations M2M).
*
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un
* echec (permission manquante, reseau) laisse simplement la liste vide.
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
interface HydraMember {
'@id': string
}
interface CategoryMember extends HydraMember {
code: string
name: string
}
interface SiteMember extends HydraMember {
name: string
postalCode: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useProviderReferentials() {
const api = useApi()
const categories = ref<RefOption[]>([])
const sites = ref<RefOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
url: string,
query: Record<string, string | string[]> = {},
): Promise<T[]> {
const res = await api.get<{ member?: T[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/** Charge en parallele les referentiels du formulaire principal (categories + sites). */
async function loadMain(): Promise<void> {
await Promise.allSettled([
// RG-3.09 : un prestataire ne porte que des categories de type
// PRESTATAIRE -> filtre cote API. Libelle affiche = `name`.
fetchAll<CategoryMember>('/categories', { typeCode: 'PRESTATAIRE' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }),
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
fetchAll<SiteMember>('/sites')
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
])
}
return {
categories,
sites,
loadMain,
}
}
@@ -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<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).
*
* 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<Provider>({ url: '/providers' })
}
@@ -0,0 +1,438 @@
<template>
<div>
<PageHeader>
{{ t('technique.providers.title') }}
<template #actions>
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
<div class="flex items-center gap-8">
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('technique.providers.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList via useProvidersRepository :
pagination serveur, tri companyName ASC par defaut (cote back),
archives masques par defaut. Cloisonnement par site cote back. -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
table-class="table-fixed providers-table"
:empty-message="t('technique.providers.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Categories : libelles (name) separes par une virgule. -->
<template #cell-categories="{ item }">
{{ formatCategories(item) }}
</template>
<!-- Sites : badges colores (name + color), relation directe du prestataire. -->
<template #cell-sites="{ item }">
<span class="flex flex-wrap gap-1">
<span
v-for="site in (item.sites as ProviderSite[])"
:key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
:style="{ backgroundColor: site.color }"
>
{{ site.name }}
</span>
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt), format JJ-MM-AAAA. -->
<template #cell-lastActivity="{ item }">
{{ formatLastActivity(item) }}
</template>
</MalioDataTable>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('technique.providers.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Voir les résultats ». Meme pattern que le repertoire fournisseurs.
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('technique.providers.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche : nom entreprise + contact + email (param `search`). -->
<MalioAccordionItem :title="t('technique.providers.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Categories (type PRESTATAIRE) : cases a cocher (multi). Valeur = code stable. -->
<MalioAccordionItem :title="t('technique.providers.filters.categories')" value="categories">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in categoryOptions"
:id="`filter-category-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftCategoryCodes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
<MalioAccordionItem :title="t('technique.providers.filters.sites')" value="sites">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in siteOptions"
:id="`filter-site-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftSiteIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<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"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('technique.providers.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Provider, ProviderSite } from '~/modules/technique/composables/useProvidersRepository'
interface FilterOption {
value: string
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('technique.providers.title') })
// Bouton « Ajouter » reserve a `manage` (POST /providers garde manage seul —
// Compta cree pas). « Exporter » et « Filtrer » suivent `view`.
const canManage = computed(() => can('technique.providers.manage'))
const canView = computed(() => can('technique.providers.view'))
const {
items: providers,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadProviders,
goToPage,
setItemsPerPage,
setFilters,
} = useProvidersRepository()
// Mappe les prestataires en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Provider. Meme pattern que fournisseurs.
const rows = computed(() => providers.value.map(provider => ({
id: provider.id,
companyName: provider.companyName,
categories: provider.categories,
sites: provider.sites,
updatedAt: provider.updatedAt,
})))
const columns = [
{ key: 'companyName', label: t('technique.providers.column.companyName') },
{ key: 'categories', label: t('technique.providers.column.categories') },
{ key: 'sites', label: t('technique.providers.column.sites') },
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
]
/** Libelles des categories du prestataire, separes par une virgule (name). */
function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Provider['categories']) ?? []
return categories.map(c => c.name).join(', ')
}
/**
* Derniere activite : date de derniere modification de la fiche (updatedAt,
* expose en liste via default:read). Format court francais JJ-MM-AAAA (tirets,
* cf. spec-front M3 § Datatable).
*/
function formatLastActivity(item: Record<string, unknown>): string {
const value = item.updatedAt as string | null | undefined
if (!value) {
return ''
}
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = date.getFullYear()
return `${day}-${month}-${year}`
}
/** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/providers/${item.id}`)
}
function goToCreate(): void {
router.push('/providers/new')
}
// ── Filtres (drawer) ────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern repertoire fournisseurs) :
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
const siteOptions = ref<FilterOption[]>([])
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('technique.providers.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
// reouverture reflete les filtres actifs.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
filterDrawerOpen.value = true
}
function toggleCategory(code: string, selected: boolean): void {
draftCategoryCodes.value = selected
? [...draftCategoryCodes.value, code]
: draftCategoryCodes.value.filter(c => c !== code)
}
function toggleSite(id: string, selected: boolean): void {
draftSiteIds.value = selected
? [...draftSiteIds.value, id]
: draftSiteIds.value.filter(s => s !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
* Les filtres vides sont omis pour une query propre.
*/
function buildFilterPayload(): Record<string, string | string[] | boolean> {
const payload: 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
return payload
}
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
setFilters({}, { replace: true })
}
/** Charge les referentiels du drawer (categories PRESTATAIRE + sites) via ?pagination=false. */
async function loadFilterOptions(): Promise<void> {
const [cats, sites] = await Promise.all([
api.get<{ member?: Array<{ code: string, name: string }> }>(
'/categories',
// Taxonomie multi-types : le filtre du repertoire prestataires ne
// propose que les categories de type PRESTATAIRE.
{ pagination: 'false', typeCode: 'PRESTATAIRE' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
api.get<{ member?: Array<{ id: number, name: string }> }>(
'/sites',
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
])
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
}
// ── Export XLSX ─────────────────────────────────────────────────────────────
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
// l'utilisateur a accounting.view (gere cote back).
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage — meme approche que
// l'export fournisseurs.
const blob = await api.get<Blob>('/providers/export.xlsx', buildFilterPayload(), {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'repertoire-prestataires.xlsx')
}
catch {
toast.error({
title: t('technique.providers.toast.error'),
message: t('technique.providers.toast.exportError'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadProviders()
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
// l'utilisateur perd juste les options de filtre.
loadFilterOptions().catch(() => {
categoryOptions.value = []
siteOptions.value = []
})
})
</script>
<style scoped>
/*
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
* couleurs/tailles (qui restent sur les defauts Malio).
*/
:deep(.providers-table tbody td:nth-child(3)) {
padding-top: 8px;
padding-bottom: 8px;
}
</style>
@@ -0,0 +1,127 @@
<template>
<div>
<!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('technique.providers.form.title') }}</h1>
</div>
<!-- Formulaire principal (pre-onglets)
Sans validation de ce bloc, les onglets restent inaccessibles. Au
succes du POST, les champs passent en lecture seule et on bascule
automatiquement sur l'onglet Contact (PAS d'onglet Information au M3).
Selecteur de site present ici (RG-3.03, relation directe). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelectCheckbox
:model-value="main.siteIris"
:options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
/>
</div>
<div v-if="!mainLocked" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
<!-- ── Onglets a validation incrementale ─────────────────────────────
Le contenu des onglets (Contact / Adresse / Comptabilite) arrive aux
tickets ERP-142 → 144 : placeholders « A venir » pour l'instant. -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #contact><ComingSoonPlaceholder /></template>
<template #address><ComingSoonPlaceholder /></template>
<template v-if="canAccountingView" #accounting><ComingSoonPlaceholder /></template>
</MalioTabList>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProviderReferentials } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
const { t } = useI18n()
const router = useRouter()
const { can } = usePermissions()
useHead({ title: t('technique.providers.form.title') })
// Gating de la route : la creation est reservee a `manage` (POST /providers garde
// manage seul — Compta ne cree pas). Compta (accounting seul) et Usine sont
// rediriges vers le repertoire.
if (!can('technique.providers.manage')) {
await navigateTo('/providers')
}
const referentials = useProviderReferentials()
const {
main,
mainLocked,
mainSubmitting,
mainErrors,
canAccountingView,
tabKeys,
activeTab,
unlockedIndex,
submitMain,
} = useProviderForm()
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
function goBack(): void {
router.push('/providers')
}
// Icone (Iconify) affichee dans l'onglet, par cle.
const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
accounting: 'mdi:bank-circle-outline',
}
// Onglets desactives tant que le formulaire principal n'est pas valide
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`technique.providers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadMain().catch(() => {})
})
</script>
@@ -0,0 +1,41 @@
/**
* Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique).
*
* Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet
* Information, et porte en plus un selecteur de site SUR le formulaire principal
* (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse).
*
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
* DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet
* Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent.
*
* Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`),
* envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 :
* `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`).
*/
/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */
export interface ProviderMainDraft {
/** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */
companyName: string | null
/** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */
categoryIris: string[]
/** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */
siteIris: string[]
}
/** Fabrique un formulaire principal vierge. */
export function emptyProviderMain(): ProviderMainDraft {
return {
companyName: null,
categoryIris: [],
siteIris: [],
}
}
/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */
export interface ProviderMainResponse {
id: number
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
companyName: string | null
}