feat(front) : consultation + modification prestataire (ERP-145) (#107)
Auto Tag Develop / tag (push) Successful in 8s
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user