feat(front) : consultation + modification prestataire (ERP-145) (#107)
Auto Tag Develop / tag (push) Successful in 8s

Empilée sur ERP-144 (#106).

## Périmètre ERP-145
Écrans **Consultation** (lecture seule) et **Modification** (édition par onglet), peuplés depuis la **seule** réponse `GET /api/providers/{id}` (embed contacts/adresses/ribs + refs comptables — pas de N+1).

### Consultation — `pages/providers/[id]/index.vue` (`/providers/{id}`)
- Ouverture par défaut sur **Contacts** ; tous champs readonly ; onglets **Contacts · Adresse · Rapports · Échanges · Comptabilité** (navigation libre). Rapports/Échanges = placeholders « À venir ».
- Flèche retour → répertoire. Bouton **Modifier** (si `manage` OU `accounting.manage`). Bouton **Archiver** (Admin seul, `archive`) → modal → PATCH `{isArchived:true}` ; **Restaurer** si archivé.
- Comptabilité visible seulement si `accounting.view` ; banque/RIB affichés selon le type de règlement (VIREMENT/LCR).

### Modification — `pages/providers/[id]/edit.vue` (`/providers/{id}/edit`)
- Pré-rempli ; **bloc principal éditable** (Nom/Catégories/Sites, PATCH `provider:write:main` via `updateMain`) ; onglets Contact/Adresse/Comptabilité en **navigation libre**, PATCH partiel par onglet (réutilise `useProviderForm` en `editMode`).
- Onglets sans permission `manage` / `accounting.manage` restent **readonly** (pas de bouton Valider / suppression). Accès réservé à `manage` OU `accounting.manage`.

### Composables / helpers
- **`useProvider(id)`** : charge le détail (ld+json) + archive/restore (PATCH isArchived seul, puis rechargement).
- **`useProviderForm`** étendu : `updateMain()` (PATCH principal en édition) + `editMode` (completeTab ne verrouille/avance plus).
- **`providerDetail.ts`** : mapping embed → brouillons + options role-indépendantes (libellés depuis l'embed) + règles d'actions (Modifier/Archiver/Restaurer).

## Conformité
- `useApi()` only ; `Malio*` only ; `usePermissions()` pour boutons/onglets ; aucun texte FR en dur ; pas d'import inter-module (règle ABSOLUE n°1).

## Vérifications
- Vitest : 470/470 (16 nouveaux : mapping détail, actions par permission, updateMain + editMode).
- ESLint : OK · `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : **Consultation** (ACME) — bloc principal readonly + libellés catégories/sites résolus depuis l'embed, 5 onglets, Modifier+Archiver visibles (admin), Comptabilité readonly. **Modification** — bloc principal éditable pré-rempli (Site « 86 17 »), 3 onglets navigation libre, onglet Contact pré-rempli.

Reviewed-on: #107
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #107.
This commit is contained in:
2026-06-15 09:29:44 +00:00
committed by Autin
parent 19ac8833eb
commit c76c447aa2
18 changed files with 1812 additions and 71 deletions
@@ -69,14 +69,14 @@ describe('useProviderForm', () => {
permState.accountingManage = false
})
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', 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.companyName).toBe('technique.providers.form.errors.nameRequired')
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)
@@ -122,18 +122,17 @@ describe('useProviderForm', () => {
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 })
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
const form = useProviderForm()
form.main.companyName = ' '
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
const created = 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] })
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
})
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
@@ -585,3 +584,70 @@ describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
expect(mockPatch).not.toHaveBeenCalled()
})
})
describe('useProviderForm — modification (ERP-145)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
permState.accountingManage = false
})
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
const form = useProviderForm()
form.editMode.value = true
form.activeTab.value = 'contact'
expect(form.completeTab('contact')).toBe(false)
expect(form.isValidated('contact')).toBe(false)
expect(form.activeTab.value).toBe('contact')
})
it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => {
mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' })
const form = useProviderForm()
form.providerId.value = 7
form.main.companyName = 'Maintenance Pro'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const ok = await form.updateMain()
expect(ok).toBe(true)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith(
'/providers/7',
{ companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] },
{ toast: false },
)
// Reaffiche le nom normalise renvoye par le serveur.
expect(form.main.companyName).toBe('MAINTENANCE PRO')
})
it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => {
const form = useProviderForm()
form.providerId.value = 7
form.main.companyName = 'X'
form.main.categoryIris = [CAT_MAINT]
const ok = await form.updateMain()
expect(ok).toBe(false)
expect(mockPatch).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
})
it('updateMain : 409 doublon -> erreur inline sur companyName', async () => {
mockPatch.mockRejectedValueOnce({ response: { status: 409 } })
const form = useProviderForm()
form.providerId.value = 7
form.main.companyName = 'Doublon'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const ok = await form.updateMain()
expect(ok).toBe(false)
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
})
})
@@ -0,0 +1,70 @@
import { ref } from 'vue'
import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail'
/**
* Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation /
* Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via
* `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs
* sous `provider:item:read` / `provider:read:accounting`) — une SEULE requete
* peuple les deux ecrans (embed borne, pas de N+1).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra
* complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage).
*
* Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration
* (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant,
* qui decide du toast a afficher.
*/
export function useProvider(id: number | string) {
const api = useApi()
const provider = ref<ProviderDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
function fetchDetail(): Promise<ProviderDetail> {
return api.get<ProviderDetail>(
`/providers/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
provider.value = await fetchDetail()
}
catch {
error.value = true
provider.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL — groupe provider:write:archive ;
* tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH
* ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles
* comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est
* propagee a l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/providers/${id}`, { isArchived }, { toast: false })
provider.value = await fetchDetail()
}
return {
provider,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -20,6 +20,7 @@ import {
import {
buildProviderContactPayload,
isProviderContactBlank,
isProviderContactNamed,
} from '~/modules/technique/utils/forms/providerContact'
import {
buildProviderAddressPayload,
@@ -91,6 +92,9 @@ export function useProviderForm() {
const activeTab = ref<string>('contact')
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
// Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de
// bascule automatique d'onglet a la validation (cf. completeTab).
const editMode = ref(false)
function isValidated(key: string): boolean {
return validated[key] === true
@@ -108,6 +112,10 @@ export function useProviderForm() {
*/
function validateMainFront(): boolean {
let valid = true
if (!main.companyName?.trim()) {
mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
valid = false
}
if (main.siteIris.length === 0) {
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
valid = false
@@ -192,12 +200,55 @@ export function useProviderForm() {
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
}
/**
* MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe
* provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09,
* 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la
* difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la
* navigation est libre en modification). Retourne true si le PATCH a reussi.
*/
async function updateMain(): Promise<boolean> {
if (providerId.value === null || mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
const updated = await api.patch<ProviderMainResponse>(
`/providers/${providerId.value}`,
buildMainPayload(),
{ toast: false },
)
main.companyName = updated.companyName ?? main.companyName
return true
}
catch (error) {
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
}
}
/**
* 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 {
// En modification : navigation libre, l'onglet reste editable apres validation.
if (editMode.value) {
return false
}
validated[key] = true
const index = tabIndex(key)
const next = tabKeys.value[index + 1]
@@ -253,10 +304,11 @@ export function useProviderForm() {
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » desactive tant que le dernier bloc est vide (RG-3.04).
// « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU
// prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && !isProviderContactBlank(last)
return last !== undefined && isProviderContactNamed(last)
})
function addContact(): void {
@@ -521,6 +573,7 @@ export function useProviderForm() {
activeTab,
unlockedIndex,
validated,
editMode,
isValidated,
// contacts
contacts,
@@ -551,6 +604,7 @@ export function useProviderForm() {
validateMainFront,
buildMainPayload,
submitMain,
updateMain,
patchProvider,
completeTab,
submitRows,