Files
Starseed/frontend/modules/technique/composables/useProviderForm.ts
T
tristan 7ad44fd366
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m38s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m20s
feat(front) : page ajout prestataire + formulaire principal
2026-06-15 10:53:41 +02:00

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,
}
}