3d4ae391fe
Auto Tag Develop / tag (push) Successful in 7s
Empilée sur ERP-142 (#104). ## Périmètre ERP-143 Onglet **Adresse** de l'écran `/providers/new` — saisie multi-adresses (blocs ajoutables) via la sous-ressource addresses. - **`ProviderAddressBlock.vue`** (miroir `SupplierAddressBlock` **simplifié**) : Sélecteur de sites (≥1, RG-3.05) / Catégories (PRESTATAIRE, RG-3.09) / Contact(s) rattaché(s) (depuis l'onglet Contact) / Pays (défaut France) / Code postal / Ville / Adresse (autocomplete BAN) / Complément. **Pas** de type d'adresse, **pas** de bennes, **pas** de triage (différence M2). - **RG-3.06** : `useAddressAutocomplete()` **réutilisé tel quel** — CP → liste des villes (BAN) ; cas dégradé (API down) → ville/adresse en saisie libre + toast unique. - **`useProviderForm`** étendu : `addresses`, `canAddAddress` (RG-3.05/3.09), `add/removeAddress`, `submitAddresses` (POST `/providers/{id}/addresses` + PATCH `/provider_addresses/{id}`, groupe `provider:write:addresses`), erreurs 422 **par ligne**. - **`useProviderReferentials`** : ajout des pays (`/countries`) pour le select Pays. - Helpers purs `utils/forms/providerAddress.ts` (`isProviderAddressValid`, `buildProviderAddressPayload` — relations en IRI, requis vides omis au POST). - « + Nouvelle adresse » / Supprimer (modal) / « Valider ». i18n `technique.providers.form.address` + `confirmDelete.address`. ## Conformité - `useApi()` only ; `Malio*` only ; aucun texte FR en dur ; `useAddressAutocomplete` non réécrit ; pas d'import inter-module (helpers ré-implémentés côté Technique, règle ABSOLUE n°1). ## Vérifications - Vitest : 436/436 (18 nouveaux : helpers adresse, bloc — BAN dégradé/allow-create/mapping erreurs, workflow adresses POST/PATCH/422 par ligne). - ESLint : OK. - `nuxi typecheck` : 0 erreur sur les fichiers source du ticket. - Golden path navigateur : page compile, onglet Contact OK. NB : l'onglet Adresse est gaté derrière la validation principal+contact (multiselect `Malio` non pilotable en a11y) — couvert par tests unitaires (montage + BAN + mapping) + typecheck. Reviewed-on: #105 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
import { computed, reactive, ref, type Ref } from 'vue'
|
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
|
import { mapViolationsToRecord } from '~/shared/utils/api'
|
|
import {
|
|
emptyProviderAddress,
|
|
emptyProviderContact,
|
|
emptyProviderMain,
|
|
type ProviderAddressFormDraft,
|
|
type ProviderAddressResponse,
|
|
type ProviderContactFormDraft,
|
|
type ProviderContactResponse,
|
|
type ProviderMainDraft,
|
|
type ProviderMainResponse,
|
|
} from '~/modules/technique/types/providerForm'
|
|
import {
|
|
buildProviderContactPayload,
|
|
isProviderContactBlank,
|
|
} from '~/modules/technique/utils/forms/providerContact'
|
|
import {
|
|
buildProviderAddressPayload,
|
|
isProviderAddressValid,
|
|
} from '~/modules/technique/utils/forms/providerAddress'
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
|
|
* on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la
|
|
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le
|
|
* fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
|
|
* true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`.
|
|
*/
|
|
async function submitRows<T>(
|
|
rows: T[],
|
|
target: Ref<Record<string, string>[]>,
|
|
saveRow: (row: T, index: number) => Promise<void>,
|
|
onUnmappedError: (error: unknown, index: number) => void,
|
|
shouldSkip?: (row: T, index: number) => boolean,
|
|
): Promise<boolean> {
|
|
target.value = []
|
|
let hasError = false
|
|
for (let index = 0; index < rows.length; index++) {
|
|
const row = rows[index] as T
|
|
if (shouldSkip?.(row, index)) {
|
|
continue
|
|
}
|
|
try {
|
|
await saveRow(row, index)
|
|
}
|
|
catch (error) {
|
|
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
|
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
|
if (Object.keys(mapped).length > 0) {
|
|
target.value[index] = mapped
|
|
}
|
|
else {
|
|
onUnmappedError(error, index)
|
|
}
|
|
hasError = true
|
|
}
|
|
}
|
|
return hasError
|
|
}
|
|
|
|
// ── Onglet Contact (ERP-142) ──────────────────────────────────────────────
|
|
const contacts = ref<ProviderContactFormDraft[]>([emptyProviderContact()])
|
|
// 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).
|
|
const canAddContact = computed(() => {
|
|
const last = contacts.value[contacts.value.length - 1]
|
|
return last !== undefined && !isProviderContactBlank(last)
|
|
})
|
|
|
|
function addContact(): void {
|
|
if (canAddContact.value) {
|
|
contacts.value.push(emptyProviderContact())
|
|
}
|
|
}
|
|
|
|
function removeContact(index: number): void {
|
|
contacts.value.splice(index, 1)
|
|
contactErrors.value.splice(index, 1)
|
|
}
|
|
|
|
/**
|
|
* Valide l'onglet Contact : POST des nouveaux contacts sur
|
|
* /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id}
|
|
* (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc
|
|
* valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour
|
|
* declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un
|
|
* onglet vide. Retourne true si l'onglet a ete valide (avance/termine).
|
|
*/
|
|
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
|
|
if (providerId.value === null || tabSubmitting.value) {
|
|
return false
|
|
}
|
|
tabSubmitting.value = true
|
|
try {
|
|
const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c))
|
|
const hasError = await submitRows(
|
|
contacts.value,
|
|
contactErrors,
|
|
async (contact) => {
|
|
const body = buildProviderContactPayload(contact)
|
|
if (contact.id === null) {
|
|
const created = await api.post<ProviderContactResponse>(
|
|
`/providers/${providerId.value}/contacts`,
|
|
body,
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
contact.id = created.id
|
|
contact.iri = created['@id'] ?? null
|
|
}
|
|
else {
|
|
await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false })
|
|
}
|
|
},
|
|
onError,
|
|
contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact),
|
|
)
|
|
if (hasError) {
|
|
return false
|
|
}
|
|
completeTab('contact')
|
|
return true
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── Onglet Adresse (ERP-143) ──────────────────────────────────────────────
|
|
const addresses = ref<ProviderAddressFormDraft[]>([emptyProviderAddress()])
|
|
// Erreurs 422 par ligne (alignees sur l'index du v-for).
|
|
const addressErrors = ref<Record<string, string>[]>([])
|
|
|
|
// « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas
|
|
// au moins un site ET une categorie (RG-3.05 / RG-3.09).
|
|
const canAddAddress = computed(() => {
|
|
const last = addresses.value[addresses.value.length - 1]
|
|
return last !== undefined && isProviderAddressValid(last)
|
|
})
|
|
|
|
function addAddress(): void {
|
|
if (canAddAddress.value) {
|
|
addresses.value.push(emptyProviderAddress())
|
|
}
|
|
}
|
|
|
|
function removeAddress(index: number): void {
|
|
addresses.value.splice(index, 1)
|
|
addressErrors.value.splice(index, 1)
|
|
}
|
|
|
|
/**
|
|
* Valide l'onglet Adresse : POST des nouvelles adresses sur
|
|
* /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id}
|
|
* (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par
|
|
* ligne. Retourne true si l'onglet a ete valide (avance/termine).
|
|
*/
|
|
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
|
if (providerId.value === null || tabSubmitting.value) {
|
|
return false
|
|
}
|
|
tabSubmitting.value = true
|
|
try {
|
|
const hasError = await submitRows(
|
|
addresses.value,
|
|
addressErrors,
|
|
async (address) => {
|
|
const body = buildProviderAddressPayload(address)
|
|
if (address.id === null) {
|
|
const created = await api.post<ProviderAddressResponse>(
|
|
`/providers/${providerId.value}/addresses`,
|
|
body,
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
address.id = created.id
|
|
}
|
|
else {
|
|
await api.patch(`/provider_addresses/${address.id}`, body, { toast: false })
|
|
}
|
|
},
|
|
onError,
|
|
)
|
|
if (hasError) {
|
|
return false
|
|
}
|
|
completeTab('address')
|
|
return true
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
return {
|
|
// etat
|
|
main,
|
|
providerId,
|
|
mainLocked,
|
|
mainSubmitting,
|
|
tabSubmitting,
|
|
mainErrors,
|
|
// onglets
|
|
canAccountingView,
|
|
tabKeys,
|
|
activeTab,
|
|
unlockedIndex,
|
|
validated,
|
|
isValidated,
|
|
// contacts
|
|
contacts,
|
|
contactErrors,
|
|
canAddContact,
|
|
addContact,
|
|
removeContact,
|
|
submitContacts,
|
|
// adresses
|
|
addresses,
|
|
addressErrors,
|
|
canAddAddress,
|
|
addAddress,
|
|
removeAddress,
|
|
submitAddresses,
|
|
// actions
|
|
validateMainFront,
|
|
buildMainPayload,
|
|
submitMain,
|
|
patchProvider,
|
|
completeTab,
|
|
submitRows,
|
|
}
|
|
}
|