d0e9f48983
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>
208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
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,
|
|
}
|
|
}
|