feat(front) : onglet contact prestataire (ERP-142) (#104)
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>
This commit was merged in pull request #104.
This commit is contained in:
2026-06-15 09:05:07 +00:00
committed by Autin
parent a6f01400ba
commit c1e45cd582
9 changed files with 709 additions and 6 deletions
@@ -57,23 +57,77 @@
</div>
<!-- ── Onglets a validation incrementale ─────────────────────────────
Le contenu des onglets (Contact / Adresse / Comptabilite) arrive aux
tickets ERP-142 → 144 : placeholders « A venir » pour l'instant. -->
Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux
tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #contact><ComingSoonPlaceholder /></template>
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="index > 0"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
@click="onSubmitContacts"
/>
</div>
</div>
</template>
<template #address><ComingSoonPlaceholder /></template>
<template v-if="canAccountingView" #accounting><ComingSoonPlaceholder /></template>
</MalioTabList>
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, onMounted, reactive } from 'vue'
import { useProviderReferentials } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
const { t } = useI18n()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('technique.providers.form.title') })
@@ -97,6 +151,14 @@ const {
activeTab,
unlockedIndex,
submitMain,
tabSubmitting,
isValidated,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
} = useProviderForm()
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
@@ -104,6 +166,50 @@ function goBack(): void {
router.push('/providers')
}
/**
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API. Retourne
* TOUJOURS une chaine (le composant de toast plante sur `undefined`).
*/
function apiErrorMessage(error: unknown): string {
const data = (error as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
// ── Onglet Contact ──────────────────────────────────────────────────────────
/** Valide l'onglet Contact ; toast de succes si l'onglet a ete finalise. */
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
function askRemoveContact(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
}
// ── Modal de confirmation generique ─────────────────────────────────────────
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
// Icone (Iconify) affichee dans l'onglet, par cle.
const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline',