feat(front) : consultation + modification prestataire

This commit is contained in:
2026-06-15 10:07:36 +02:00
parent c25c33116d
commit 954f35abc2
8 changed files with 1462 additions and 1 deletions
@@ -585,3 +585,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),
}
}
@@ -91,6 +91,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
@@ -192,12 +195,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]
@@ -521,6 +567,7 @@ export function useProviderForm() {
activeTab,
unlockedIndex,
validated,
editMode,
isValidated,
// contacts
contacts,
@@ -551,6 +598,7 @@ export function useProviderForm() {
validateMainFront,
buildMainPayload,
submitMain,
updateMain,
patchProvider,
completeTab,
submitRows,