c1e45cd582
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-141 (#103). ## Périmètre ERP-142 Onglet **Contact** de l'écran `/providers/new` — saisie multi-contacts (blocs ajoutables) via la sous-ressource contacts. - **`ProviderContactBlock.vue`** (miroir `SupplierContactBlock`) : Nom / Prénom / Fonction / Email / Téléphone (x1, +1 révélable, **max 2**), erreurs 422 par champ (prop `:errors`). - **`useProviderForm`** étendu : état `contacts`, `canAddContact` (RG-3.04), `addContact`/`removeContact`, `submitContacts` (POST `/providers/{id}/contacts` pour les nouveaux, PATCH `/provider_contacts/{id}` pour les existants, groupe `provider:write:contacts`), `submitRows` (erreurs collectées **par ligne**, non bloquant). - **RG-3.04** : « + Nouveau contact » désactivé tant que le bloc courant est vide (≥1 champ parmi prénom/nom/fonction/tél/email — aligné back). - **RG-3.12** : onglet non validable vide ; une amorce vide est soumise pour déclencher la 422 `firstName` inline. - Suppression d'un bloc → modal de confirmation. - Helpers purs `utils/forms/providerContact.ts` (`isProviderContactBlank`, `buildProviderContactPayload`). - i18n `technique.providers.form.contact/confirmDelete` + `toast.updateSuccess`. ## Vérifications - Vitest : 418/418 (16 nouveaux : helpers, bloc, workflow contacts). - ESLint : OK. - `nuxi typecheck` : 0 erreur sur les fichiers source du ticket. - Golden path navigateur : bloc Contact rendu, « Nouveau contact » désactivé tant que vide puis activé après saisie, révélation du 2e téléphone (max 2). Reviewed-on: #104 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
import { computed, reactive, ref, type Ref } from 'vue'
|
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
|
import { mapViolationsToRecord } from '~/shared/utils/api'
|
|
import {
|
|
emptyProviderContact,
|
|
emptyProviderMain,
|
|
type ProviderContactFormDraft,
|
|
type ProviderContactResponse,
|
|
type ProviderMainDraft,
|
|
type ProviderMainResponse,
|
|
} from '~/modules/technique/types/providerForm'
|
|
import {
|
|
buildProviderContactPayload,
|
|
isProviderContactBlank,
|
|
} from '~/modules/technique/utils/forms/providerContact'
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
}
|
|
|
|
return {
|
|
// etat
|
|
main,
|
|
providerId,
|
|
mainLocked,
|
|
mainSubmitting,
|
|
tabSubmitting,
|
|
mainErrors,
|
|
// onglets
|
|
canAccountingView,
|
|
tabKeys,
|
|
activeTab,
|
|
unlockedIndex,
|
|
validated,
|
|
isValidated,
|
|
// contacts
|
|
contacts,
|
|
contactErrors,
|
|
canAddContact,
|
|
addContact,
|
|
removeContact,
|
|
submitContacts,
|
|
// actions
|
|
validateMainFront,
|
|
buildMainPayload,
|
|
submitMain,
|
|
patchProvider,
|
|
completeTab,
|
|
submitRows,
|
|
}
|
|
}
|