Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions 19ac8833eb chore: bump version to v0.1.119
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-15 09:19:42 +00:00
tristan c25c33116d feat(front) : onglet comptabilite prestataire (ERP-144) (#106)
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-143 (#105).

## Périmètre ERP-144
Onglet **Comptabilité** de l'écran `/providers/new` — gated par permission + blocs RIB conditionnels.

- Champs (`Malio*`) : SIREN / Numéro de compte / Mode de TVA (`/api/tva_modes`) / N° de TVA / Délai (`/api/payment_delays`) / Type de règlement (`/api/payment_types`) / Banque (`/api/banks`).
- **RG-3.07** : Banque visible **et** obligatoire **seulement si** Type = `VIREMENT` (affichage conditionnel + payload `bank` forcé à null sinon).
- **RG-3.08** : blocs RIB (Libellé/BIC/IBAN) affichés et requis si Type = `LCR` ; « + RIB » gated (dernier RIB complet) / Supprimer (modal). À la validation, **POST des RIB AVANT** le PATCH des scalaires (le back valide RG-3.08 sur le PATCH).
- **Gating** : onglet présent uniquement si `technique.providers.accounting.view` ; **éditable** uniquement si `.manage` (sinon lecture seule). Masqué pour Bureau/Commerciale.
- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs (`/providers/{id}/ribs` + `/provider_ribs/{id}`). Erreurs 422 inline (scalaires) et par ligne (RIB).
- `useProviderReferentials.loadAccounting()` (chargé seulement si l'onglet est accessible). Helpers purs `utils/forms/providerAccounting.ts`.
- i18n `technique.providers.form.accounting` + `confirmDelete.rib`.

> NB : les placeholders **Rapports / Échanges** relèvent des écrans Consultation/Modification (ERP-145) — le flux de **création** ne porte que 3 onglets (Contact/Adresse/Comptabilité), conformément à la spec.

## Conformité
- `useApi()` only ; `Malio*` only ; pas de masque email ; aucun texte FR en dur ; pas d'import inter-module (helpers ré-implémentés côté Technique, règle ABSOLUE n°1).

## Vérifications
- Vitest : 454/454 (18 nouveaux : helpers compta RG-3.07/3.08, workflow VIREMENT/LCR, ordre RIB→scalaires, 422 inline + par ligne, lecture seule sans manage).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : page compile, onglet Comptabilité visible (gating accounting.view OK pour admin). Contenu de l'onglet gaté derrière le déverrouillage des 3 onglets (multiselect `Malio` non pilotable en a11y) — couvert par les tests unitaires + typecheck.

Reviewed-on: #106
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 09:15:20 +00:00
gitea-actions 17aa61d014 chore: bump version to v0.1.118
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 40s
2026-06-15 09:14:47 +00:00
tristan 3d4ae391fe feat(front) : onglet adresse prestataire (ERP-143) (#105)
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>
2026-06-15 09:12:50 +00:00
gitea-actions 04c794addb chore: bump version to v0.1.117
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 39s
2026-06-15 09:09:56 +00:00
tristan c1e45cd582 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>
2026-06-15 09:05:07 +00:00
17 changed files with 2395 additions and 13 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.116' app.version: '0.1.119'
+49 -1
View File
@@ -406,12 +406,60 @@
"errors": { "errors": {
"siteRequired": "Sélectionnez au moins un site.", "siteRequired": "Sélectionnez au moins un site.",
"categoryRequired": "Sélectionnez au moins une catégorie." "categoryRequired": "Sélectionnez au moins une catégorie."
},
"contact": {
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
"email": "Email",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"remove": "Supprimer le contact",
"add": "Nouveau contact"
},
"address": {
"sites": "Sites",
"categories": "Catégorie",
"contacts": "Contact(s) rattaché(s)",
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
"street": "Adresse",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"streetComplement": "Adresse complémentaire",
"remove": "Supprimer l'adresse",
"add": "Nouvelle adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
"nTva": "N° de TVA",
"paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement",
"bank": "Banque",
"ribLabel": "Libellé",
"ribBic": "BIC",
"ribIban": "IBAN",
"addRib": "Ajouter un RIB",
"removeRib": "Supprimer le RIB"
},
"confirmDelete": {
"title": "Confirmer la suppression",
"cancel": "Annuler",
"confirm": "Supprimer",
"contact": "Supprimer ce contact ?",
"address": "Supprimer cette adresse ?",
"rib": "Supprimer ce RIB ?"
} }
}, },
"toast": { "toast": {
"error": "Une erreur est survenue. Réessayez.", "error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.", "exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
"createSuccess": "Prestataire créé avec succès" "createSuccess": "Prestataire créé avec succès",
"updateSuccess": "Prestataire mis à jour avec succès"
} }
} }
}, },
@@ -0,0 +1,269 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('technique.providers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('technique.providers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
<MalioSelectCheckbox
:model-value="model.contactIris"
:options="contactOptions"
:label="t('technique.providers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('technique.providers.form.address.country')"
:readonly="readonly"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')"
:readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: ProviderAddressFormDraft
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
categoryOptions: RefOption[]
/** Sites Starseed disponibles. */
siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */
contactOptions: RefOption[]
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: ProviderAddressFormDraft]
'remove': []
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
'degraded': []
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue)
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
const degraded = ref(false)
let unavailableNotified = false
const banCityOptions = ref<RefOption[]>([])
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
const cityOptions = computed<RefOption[]>(() => {
const current = props.modelValue.city
if (current && !banCityOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banCityOptions.value]
}
return banCityOptions.value
})
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
unavailableNotified = true
emit('degraded')
}
}
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
}
try {
const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
if (query.trim().length < 3) {
banAddressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
banAddressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
function onAddressSelect(option: { label: string, value: string | number } | null): void {
if (option === null) {
return
}
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city,
postalCode: suggestion.postalCode,
})
}
</script>
@@ -0,0 +1,108 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
:model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')"
:readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')"
:readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
:model-value="model.email"
:label="t('technique.providers.form.contact.email')"
:readonly="readonly"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
:model-value="model.phonePrimary"
:label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary"
:label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
<script setup lang="ts">
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: ProviderContactFormDraft
/** Affiche l'icone de suppression (1er bloc non supprimable). */
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: ProviderContactFormDraft]
'remove': []
}>()
const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
function revealSecondaryPhone(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
}
</script>
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
import ProviderAddressBlock from '../ProviderAddressBlock.vue'
// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2.
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: searchCityMock,
searchAddress: searchAddressMock,
}),
}))
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: {
modelValue: { type: [String, Number, null], default: undefined },
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
loading: { type: Boolean, default: false },
minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', {
'data-testid': 'addr-autocomplete',
'data-options': JSON.stringify(props.options.map(o => o.value)),
})
},
})
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
return mount(ProviderAddressBlock, {
props: {
modelValue: { ...emptyProviderAddress(), ...overrides },
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => {
it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => {
const wrapper = mountBlock()
// Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3.
expect(wrapper.find('malio-input-number-stub').exists()).toBe(false)
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false)
// Aucun select ne porte le label « type d'adresse ».
const hasAddressType = wrapper.findAll('malio-select-stub').some(
el => el.attributes('label') === 'technique.providers.form.address.addressType',
)
expect(hasAddressType).toBe(false)
})
})
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
const wrapper = mountBlock({}, {
sites: 'Au moins un site est obligatoire.',
categories: 'Au moins une catégorie est obligatoire.',
})
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
})
it('affiche l\'erreur serveur sur le code postal', () => {
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
const field = wrapper.findAll('malio-input-text-stub').find(
el => el.attributes('label') === 'technique.providers.form.address.postalCode',
)
expect(field?.attributes('error')).toBe('Code postal invalide.')
})
})
describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchAddressMock.mockReset()
})
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
const wrapper = mountBlock()
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
searchAddressMock
.mockRejectedValueOnce(new Error('BAN indisponible'))
.mockResolvedValueOnce([
{ label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' },
])
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue du test')
await flushPromises()
auto.vm.$emit('search', 'rue du teste')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(2)
})
it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue du test')
await flushPromises()
auto.vm.$emit('search', 'rue du teste')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
const wrapper = mountBlock()
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
})
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
const wrapper = mountBlock({ street: '1 rue du Test' })
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
expect(values).toContain('1 rue du Test')
})
})
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
import ProviderContactBlock from '../ProviderContactBlock.vue'
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
function errorProbe(testid: string) {
return defineComponent({
name: `Probe-${testid}`,
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
},
})
}
function mountBlock(errors?: Record<string, string>) {
return mount(ProviderContactBlock, {
props: {
modelValue: emptyProviderContact(),
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputPhone: true,
MalioInputText: errorProbe('contact-text'),
MalioInputEmail: errorProbe('contact-email'),
},
},
})
}
describe('ProviderContactBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
const wrapper = mountBlock({ email: 'L\'adresse email n\'est pas valide.' })
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('L\'adresse email n\'est pas valide.')
})
it('laisse les champs sans erreur quand errors est absent', () => {
const wrapper = mountBlock()
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
})
})
@@ -19,8 +19,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockPost = vi.hoisted(() => vi.fn()) const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn()) const mockPatch = vi.hoisted(() => vi.fn())
// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite). // Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite).
const permState = vi.hoisted(() => ({ accountingView: false })) const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false }))
vi.stubGlobal('useApi', () => ({ vi.stubGlobal('useApi', () => ({
get: vi.fn(), get: vi.fn(),
@@ -37,19 +37,36 @@ vi.stubGlobal('useToast', () => ({
info: vi.fn(), info: vi.fn(),
})) }))
vi.stubGlobal('usePermissions', () => ({ vi.stubGlobal('usePermissions', () => ({
can: (perm: string) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true, can: (perm: string) => {
if (perm === 'technique.providers.accounting.view') return permState.accountingView
if (perm === 'technique.providers.accounting.manage') return permState.accountingManage
return true
},
})) }))
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm') const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm')
type ProviderForm = ReturnType<typeof useProviderForm>
const SITE_86 = '/api/sites/1' const SITE_86 = '/api/sites/1'
const CAT_MAINT = '/api/categories/7' const CAT_MAINT = '/api/categories/7'
/** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */
function contactAt(form: ProviderForm, index = 0) {
return form.contacts.value[index] ?? emptyProviderContact()
}
/** Accede a un bloc adresse (idem). */
function addressAt(form: ProviderForm, index = 0) {
return form.addresses.value[index] ?? emptyProviderAddress()
}
describe('useProviderForm', () => { describe('useProviderForm', () => {
beforeEach(() => { beforeEach(() => {
mockPost.mockReset() mockPost.mockReset()
mockPatch.mockReset() mockPatch.mockReset()
permState.accountingView = false permState.accountingView = false
permState.accountingManage = false
}) })
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => { it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
@@ -190,3 +207,381 @@ describe('useProviderForm', () => {
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false }) expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
}) })
}) })
describe('useProviderForm — onglet Contact (ERP-142)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
permState.accountingManage = false
})
/** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
function createdForm() {
const form = useProviderForm()
form.providerId.value = 7
return form
}
it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier bloc est vide', () => {
const form = createdForm()
expect(form.canAddContact.value).toBe(false)
// addContact est un no-op tant que le bloc est vide.
form.addContact()
expect(form.contacts.value).toHaveLength(1)
contactAt(form).lastName = 'Doe'
expect(form.canAddContact.value).toBe(true)
form.addContact()
expect(form.contacts.value).toHaveLength(2)
})
it('removeContact retire le bloc et son erreur de ligne', () => {
const form = createdForm()
contactAt(form).lastName = 'Doe'
form.addContact()
form.contactErrors.value = [{}, { lastName: 'x' }]
form.removeContact(1)
expect(form.contacts.value).toHaveLength(1)
expect(form.contactErrors.value).toHaveLength(1)
})
it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => {
mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 })
const form = createdForm()
contactAt(form).lastName = 'Doe'
const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers/7/contacts')
expect(body).toMatchObject({ lastName: 'Doe' })
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(contactAt(form).id).toBe(55)
expect(contactAt(form).iri).toBe('/api/provider_contacts/55')
expect(form.isValidated('contact')).toBe(true)
})
it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
contactAt(form).id = 55
contactAt(form).lastName = 'Doe'
await form.submitContacts(vi.fn())
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
})
it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName inline', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
},
})
const form = createdForm()
const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(false)
expect(mockPost).toHaveBeenCalledTimes(1)
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
expect(form.isValidated('contact')).toBe(false)
})
it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => {
mockPost
.mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 })
.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] },
},
})
const form = createdForm()
contactAt(form).lastName = 'Doe'
form.addContact()
contactAt(form, 1).email = 'invalide'
const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(false)
expect(form.contactErrors.value[0]).toBeUndefined()
expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.')
})
})
describe('useProviderForm — onglet Adresse (ERP-143)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
permState.accountingManage = false
})
/** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */
function createdForm() {
const form = useProviderForm()
form.providerId.value = 7
return form
}
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
function fillValidAddress(form: ProviderForm, index = 0): void {
const a = addressAt(form, index)
a.siteIris = [SITE_86]
a.categoryIris = [CAT_MAINT]
a.postalCode = '86100'
a.city = 'Châtellerault'
a.street = '1 rue du Test'
}
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
const form = createdForm()
expect(form.canAddAddress.value).toBe(false)
// no-op tant que l'adresse n'est pas valide.
form.addAddress()
expect(form.addresses.value).toHaveLength(1)
addressAt(form).siteIris = [SITE_86]
expect(form.canAddAddress.value).toBe(false) // categorie manquante
addressAt(form).categoryIris = [CAT_MAINT]
expect(form.canAddAddress.value).toBe(true)
form.addAddress()
expect(form.addresses.value).toHaveLength(2)
})
it('removeAddress retire le bloc et son erreur de ligne', () => {
const form = createdForm()
fillValidAddress(form)
form.addAddress()
form.addressErrors.value = [{}, { city: 'x' }]
form.removeAddress(1)
expect(form.addresses.value).toHaveLength(1)
expect(form.addressErrors.value).toHaveLength(1)
})
it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => {
mockPost.mockResolvedValueOnce({ id: 88 })
const form = createdForm()
fillValidAddress(form)
const ok = await form.submitAddresses(vi.fn())
expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers/7/addresses')
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(addressAt(form).id).toBe(88)
expect(form.isValidated('address')).toBe(true)
})
it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillValidAddress(form)
addressAt(form).id = 88
await form.submitAddresses(vi.fn())
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false })
})
it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] },
},
})
const form = createdForm()
fillValidAddress(form)
const ok = await form.submitAddresses(vi.fn())
expect(ok).toBe(false)
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.')
expect(form.isValidated('address')).toBe(false)
})
})
describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
const TVA = '/api/tva_modes/1'
const DELAY = '/api/payment_delays/1'
const TYPE = '/api/payment_types/3'
const BANK = '/api/banks/2'
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = true
permState.accountingManage = true
})
/** Prestataire cree, onglet Comptabilite editable (view + manage). */
function createdForm() {
const form = useProviderForm()
form.providerId.value = 7
return form
}
/** Remplit les scalaires comptables communs. */
function fillScalars(form: ProviderForm): void {
form.accounting.siren = '123456789'
form.accounting.accountNumber = '4010'
form.accounting.tvaModeIri = TVA
form.accounting.nTva = 'FR123'
form.accounting.paymentDelayIri = DELAY
form.accounting.paymentTypeIri = TYPE
}
it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => {
permState.accountingManage = false
const form = createdForm()
expect(form.accountingReadonly.value).toBe(true)
permState.accountingManage = true
const form2 = createdForm()
expect(form2.accountingReadonly.value).toBe(false)
})
it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => {
const form = createdForm()
form.accounting.bankIri = BANK
// Type VIREMENT -> banque requise, conservee.
form.setPaymentType(TYPE, true, false)
expect(form.accounting.bankIri).toBe(BANK)
// Type non-VIREMENT -> banque videe (sans objet).
form.setPaymentType(TYPE, false, false)
expect(form.accounting.bankIri).toBeNull()
})
it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => {
const form = createdForm()
expect(form.ribs.value).toHaveLength(0)
form.setPaymentType(TYPE, false, true)
expect(form.ribs.value).toHaveLength(1)
})
it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => {
const form = createdForm()
form.setPaymentType(TYPE, false, true)
expect(form.canAddRib.value).toBe(false)
const rib = form.ribs.value[0]
if (rib) {
rib.label = 'Compte'
rib.bic = 'BNPAFRPP'
rib.iban = 'FR76...'
}
expect(form.canAddRib.value).toBe(true)
})
it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillScalars(form)
form.accounting.bankIri = BANK
const ok = await form.submitAccounting(true, false, vi.fn())
expect(ok).toBe(true)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith(
'/providers/7',
expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }),
{ toast: false },
)
expect(form.isValidated('accounting')).toBe(true)
})
it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillScalars(form)
form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false)
await form.submitAccounting(false, false, vi.fn())
const body = mockPatch.mock.calls[0]?.[1] as Record<string, unknown>
expect(body.bank).toBeNull()
})
it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => {
mockPost.mockResolvedValueOnce({ id: 50 })
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillScalars(form)
form.setPaymentType(TYPE, false, true)
const rib = form.ribs.value[0]
if (rib) {
rib.label = 'Compte'
rib.bic = 'BNPAFRPP'
rib.iban = 'FR76...'
}
const ok = await form.submitAccounting(false, true, vi.fn())
expect(ok).toBe(true)
expect(mockPost).toHaveBeenCalledWith(
'/providers/7/ribs',
expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }),
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
expect(form.ribs.value[0]?.id).toBe(50)
// Le PATCH des scalaires intervient APRES la creation du RIB.
expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false })
})
it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => {
mockPatch.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] },
},
})
const form = createdForm()
fillScalars(form)
const ok = await form.submitAccounting(true, false, vi.fn())
expect(ok).toBe(false)
expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.')
expect(form.isValidated('accounting')).toBe(false)
})
it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] },
},
})
const form = createdForm()
fillScalars(form)
form.setPaymentType(TYPE, false, true)
const rib = form.ribs.value[0]
if (rib) {
rib.label = 'Compte'
rib.bic = 'BNPAFRPP'
}
const ok = await form.submitAccounting(false, true, vi.fn())
expect(ok).toBe(false)
expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.')
expect(mockPatch).not.toHaveBeenCalled()
})
})
@@ -1,10 +1,36 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors' import { useFormErrors } from '~/shared/composables/useFormErrors'
import { mapViolationsToRecord } from '~/shared/utils/api'
import { import {
emptyProviderAccounting,
emptyProviderAddress,
emptyProviderContact,
emptyProviderMain, emptyProviderMain,
emptyProviderRib,
type ProviderAccountingDraft,
type ProviderAddressFormDraft,
type ProviderAddressResponse,
type ProviderContactFormDraft,
type ProviderContactResponse,
type ProviderMainDraft, type ProviderMainDraft,
type ProviderMainResponse, type ProviderMainResponse,
type ProviderRibFormDraft,
type ProviderRibResponse,
} from '~/modules/technique/types/providerForm' } from '~/modules/technique/types/providerForm'
import {
buildProviderContactPayload,
isProviderContactBlank,
} from '~/modules/technique/utils/forms/providerContact'
import {
buildProviderAddressPayload,
isProviderAddressValid,
} from '~/modules/technique/utils/forms/providerAddress'
import {
buildProviderAccountingPayload,
buildProviderRibPayload,
isRibBlank,
isRibComplete,
} from '~/modules/technique/utils/forms/providerAccounting'
/** /**
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) — * Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
@@ -57,6 +83,7 @@ export function useProviderForm() {
// ── Onglets : ordre + gating progressif ─────────────────────────────────── // ── Onglets : ordre + gating progressif ───────────────────────────────────
const canAccountingView = computed(() => can('technique.providers.accounting.view')) const canAccountingView = computed(() => can('technique.providers.accounting.view'))
const canAccountingManage = computed(() => can('technique.providers.accounting.manage'))
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value)) const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree). // Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
@@ -182,6 +209,303 @@ export function useProviderForm() {
return false 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
}
}
// ── Onglet Comptabilite (ERP-144) ─────────────────────────────────────────
const accounting = reactive<ProviderAccountingDraft>(emptyProviderAccounting())
const ribs = ref<ProviderRibFormDraft[]>([])
const accountingErrors = useFormErrors()
// Erreurs 422 par ligne de RIB (alignees sur l'index du v-for).
const ribErrors = ref<Record<string, string>[]>([])
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
/**
* Met a jour le type de reglement (IRI) en propageant ses RG inter-champs :
* - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ;
* - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on
* purge les erreurs de RIB (les blocs sont conserves mais non persistes).
* `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a
* partir du code resolu via les referentiels.
*/
function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void {
accounting.paymentTypeIri = iri
if (!isBankRequired) {
accounting.bankIri = null
}
if (isRibRequired) {
if (ribs.value.length === 0) {
ribs.value.push(emptyProviderRib())
}
}
else {
ribErrors.value = []
}
}
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08).
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void {
if (canAddRib.value) {
ribs.value.push(emptyProviderRib())
}
}
function removeRib(index: number): void {
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (sous LCR).
if (ribs.value.length === 0) {
ribs.value.push(emptyProviderRib())
}
}
/**
* Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord
* (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister
* AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting,
* banque envoyee seulement si VIREMENT — RG-3.07). Erreurs RIB par ligne ;
* erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete
* valide.
*/
async function submitAccounting(
isBankRequired: boolean,
isRibRequired: boolean,
onRibError: (error: unknown) => void,
): Promise<boolean> {
if (providerId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee
// s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli)
// on la soumet pour declencher la 422 NotBlank inline.
if (isRibRequired) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
const body = buildProviderRibPayload(rib)
if (rib.id === null) {
const created = await api.post<ProviderRibResponse>(
`/providers/${providerId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false })
}
},
onRibError,
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) {
return false
}
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(
`/providers/${providerId.value}`,
buildProviderAccountingPayload(accounting, isBankRequired),
{ toast: false },
)
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
return false
}
completeTab('accounting')
return true
}
finally {
tabSubmitting.value = false
}
}
return { return {
// etat // etat
main, main,
@@ -192,16 +516,43 @@ export function useProviderForm() {
mainErrors, mainErrors,
// onglets // onglets
canAccountingView, canAccountingView,
canAccountingManage,
tabKeys, tabKeys,
activeTab, activeTab,
unlockedIndex, unlockedIndex,
validated, validated,
isValidated, isValidated,
// contacts
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
// adresses
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
// comptabilite
accounting,
ribs,
accountingErrors,
ribErrors,
accountingReadonly,
setPaymentType,
canAddRib,
addRib,
removeRib,
submitAccounting,
// actions // actions
validateMainFront, validateMainFront,
buildMainPayload, buildMainPayload,
submitMain, submitMain,
patchProvider, patchProvider,
completeTab, completeTab,
submitRows,
} }
} }
@@ -28,10 +28,20 @@ export interface RefOption {
label: string label: string
} }
/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
export interface PaymentTypeOption extends RefOption {
code: string
}
interface HydraMember { interface HydraMember {
'@id': string '@id': string
} }
interface ReferentialMember extends HydraMember {
code: string
label: string
}
interface CategoryMember extends HydraMember { interface CategoryMember extends HydraMember {
code: string code: string
name: string name: string
@@ -42,6 +52,11 @@ interface SiteMember extends HydraMember {
postalCode: string postalCode: string
} }
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' } const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useProviderReferentials() { export function useProviderReferentials() {
@@ -49,6 +64,12 @@ export function useProviderReferentials() {
const categories = ref<RefOption[]>([]) const categories = ref<RefOption[]>([])
const sites = ref<RefOption[]>([]) const sites = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
// Referentiels comptables (charges a la demande via loadAccounting).
const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */ /** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>( async function fetchAll<T extends HydraMember>(
@@ -74,12 +95,42 @@ export function useProviderReferentials() {
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ». // du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }), .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
// `country` en chaine libre, « France »...). value === label. Aligne sur
// les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
])
}
/**
* Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele
* uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient
* (allSettled) : un referentiel en echec reste vide.
*/
async function loadAccounting(): Promise<void> {
await Promise.allSettled([
fetchAll<ReferentialMember>('/tva_modes')
.then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays')
.then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }),
// Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR).
fetchAll<ReferentialMember>('/payment_types')
.then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }),
fetchAll<ReferentialMember>('/banks')
.then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }),
]) ])
} }
return { return {
categories, categories,
sites, sites,
countries,
tvaModes,
paymentDelays,
paymentTypes,
banks,
loadMain, loadMain,
loadAccounting,
} }
} }
@@ -57,23 +57,248 @@
</div> </div>
<!-- ── Onglets a validation incrementale ───────────────────────────── <!-- ── Onglets a validation incrementale ─────────────────────────────
Le contenu des onglets (Contact / Adresse / Comptabilite) arrive aux Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux
tickets ERP-142 → 144 : placeholders « A venir » pour l'instant. --> tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #contact><ComingSoonPlaceholder /></template> <!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
<template #address><ComingSoonPlaceholder /></template> <template #contact>
<template v-if="canAccountingView" #accounting><ComingSoonPlaceholder /></template> <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>
<!-- Onglet Adresse : saisie multi-adresses (blocs ajoutables). -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ProviderAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!isValidated('address')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
@click="onSubmitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<!-- Banque : visible et obligatoire seulement si VIREMENT (RG-3.07). -->
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
@click="onSubmitAccounting"
/>
</div>
</div>
</template>
</MalioTabList> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useProviderReferentials } from '~/modules/technique/composables/useProviderReferentials' import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm' import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
import {
isBankRequiredForPaymentType,
isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const toast = useToast()
const { can } = usePermissions() const { can } = usePermissions()
useHead({ title: t('technique.providers.form.title') }) useHead({ title: t('technique.providers.form.title') })
@@ -97,6 +322,30 @@ const {
activeTab, activeTab,
unlockedIndex, unlockedIndex,
submitMain, submitMain,
tabSubmitting,
isValidated,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
accounting,
ribs,
accountingErrors,
ribErrors,
accountingReadonly,
setPaymentType,
canAddRib,
addRib,
removeRib,
submitAccounting,
} = useProviderForm() } = useProviderForm()
/** Retour vers le repertoire prestataires (fleche d'en-tete). */ /** Retour vers le repertoire prestataires (fleche d'en-tete). */
@@ -104,6 +353,137 @@ function goBack(): void {
router.push('/providers') 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))
}
// ── Onglet Adresse ────────────────────────────────────────────────────────────
// Contacts deja persistes (IRI non nul), rattachables a une adresse (M2M). Le
// libelle reprend le nom complet, a defaut l'email.
const contactOptions = computed<RefOption[]>(() =>
contacts.value
.filter(c => c.iri !== null)
.map(c => ({
value: c.iri as string,
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
})),
)
// Pays : France garantie en tete meme si /countries echoue (resilience ERP-102),
// pour rester preselectionnable par defaut sur chaque adresse.
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
const addressDegradedNotified = ref(false)
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade (RG-3.06). */
function onAddressDegraded(): void {
if (addressDegradedNotified.value) {
return
}
addressDegradedNotified.value = true
toast.warning({
title: t('technique.providers.toast.error'),
message: t('technique.providers.form.address.degraded'),
})
}
/** Valide l'onglet Adresse ; toast de succes si l'onglet a ete finalise. */
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
function askRemoveAddress(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
}
// ── Onglet Comptabilite ───────────────────────────────────────────────────────
// Code stable du type de reglement selectionne (pour RG-3.07 / RG-3.08).
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-3.08).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
/** Changement de type de reglement : propage les RG inter-champs (banque / RIB). */
function onPaymentTypeChange(value: string | number | null): void {
const iri = value === null ? null : String(value)
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
}
function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
/** Valide l'onglet Comptabilite ; toast de succes si l'onglet a ete finalise. */
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
isRibRequired.value,
error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}),
)
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
// ── 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. // Icone (Iconify) affichee dans l'onglet, par cle.
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline', contact: 'mdi:account-box-plus-outline',
@@ -123,5 +503,9 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
onMounted(() => { onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides. // Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadMain().catch(() => {}) referentials.loadMain().catch(() => {})
// Referentiels comptables charges uniquement si l'onglet est accessible.
if (canAccountingView.value) {
referentials.loadAccounting().catch(() => {})
}
}) })
</script> </script>
@@ -39,3 +39,139 @@ export interface ProviderMainResponse {
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */ /** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
companyName: string | null companyName: string | null
} }
/**
* Un contact du prestataire (onglet Contact, ERP-142). Miroir de
* `SupplierContactFormDraft` (M2). Tous les champs sont nullable cote ORM ; la
* validite (RG-3.04) tient a la presence d'AU MOINS un champ rempli parmi
* prenom / nom / fonction / telephone principal / email (cf. back).
*/
export interface ProviderContactFormDraft {
/** Id serveur une fois le contact cree (null tant que non persiste). */
id: number | null
/** IRI Hydra du contact cree — servira au rattachement M2M cote adresse (ERP-143). */
iri: string | null
firstName: string | null
lastName: string | null
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
/** UI : le 2e numero a ete revele via le bouton « + » (max 2 telephones). */
hasSecondaryPhone: boolean
}
/** Fabrique un contact vierge. */
export function emptyProviderContact(): ProviderContactFormDraft {
return {
id: null,
iri: null,
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
hasSecondaryPhone: false,
}
}
/** Reponse du POST /providers/{id}/contacts (groupe provider:item:read + IRI Hydra). */
export interface ProviderContactResponse {
'@id'?: string
id: number
}
/**
* Une adresse du prestataire (onglet Adresse, ERP-143). Version SIMPLIFIEE de
* `SupplierAddressFormDraft` (M2) : PAS de type d'adresse (Prospect/Depart/Rendu),
* PAS de bennes, PAS de prestation de triage. Champs postaux + M2M sites /
* categories / contacts (par IRI).
*/
export interface ProviderAddressFormDraft {
/** Id serveur une fois l'adresse creee (null tant que non persistee). */
id: number | null
/** Pays (chaine libre, defaut « France »). */
country: string
postalCode: string | null
city: string | null
street: string | null
streetComplement: string | null
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
categoryIris: string[]
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
siteIris: string[]
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
contactIris: string[]
}
/** Fabrique une adresse vierge (France presaisi). */
export function emptyProviderAddress(): ProviderAddressFormDraft {
return {
id: null,
country: 'France',
postalCode: null,
city: null,
street: null,
streetComplement: null,
categoryIris: [],
siteIris: [],
contactIris: [],
}
}
/** Reponse du POST /providers/{id}/addresses (id suffisant pour le suivi cote front). */
export interface ProviderAddressResponse {
id: number
}
/**
* Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`).
* Relations (TVA / delai / type de reglement / banque) portees par leur IRI.
*/
export interface ProviderAccountingDraft {
siren: string | null
accountNumber: string | null
tvaModeIri: string | null
nTva: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
/** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */
bankIri: string | null
}
/** Fabrique un onglet Comptabilite vierge. */
export function emptyProviderAccounting(): ProviderAccountingDraft {
return {
siren: null,
accountNumber: null,
tvaModeIri: null,
nTva: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
}
}
/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */
export interface ProviderRibFormDraft {
id: number | null
label: string | null
bic: string | null
iban: string | null
}
/** Fabrique un RIB vierge. */
export function emptyProviderRib(): ProviderRibFormDraft {
return {
id: null,
label: null,
bic: null,
iban: null,
}
}
/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */
export interface ProviderRibResponse {
id: number
}
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import {
buildProviderAccountingPayload,
buildProviderRibPayload,
isBankRequiredForPaymentType,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
} from '../providerAccounting'
import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm'
/**
* Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs
* RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads.
*/
describe('providerAccounting helpers', () => {
describe('RG-3.07 / RG-3.08 — type de reglement', () => {
it('banque requise uniquement pour VIREMENT', () => {
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false)
expect(isBankRequiredForPaymentType(null)).toBe(false)
})
it('RIB requis uniquement pour LCR', () => {
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
expect(isRibRequiredForPaymentType(null)).toBe(false)
})
})
describe('isRibBlank / isRibComplete', () => {
it('un RIB vierge est vide et incomplet', () => {
expect(isRibBlank(emptyProviderRib())).toBe(true)
expect(isRibComplete(emptyProviderRib())).toBe(false)
})
it('un RIB partiel n\'est ni vide ni complet', () => {
const rib = { ...emptyProviderRib(), iban: 'FR76...' }
expect(isRibBlank(rib)).toBe(false)
expect(isRibComplete(rib)).toBe(false)
})
it('un RIB avec libelle + BIC + IBAN est complet', () => {
const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }
expect(isRibComplete(rib)).toBe(true)
})
})
describe('buildProviderAccountingPayload (RG-3.07)', () => {
it('envoie la banque si requise (VIREMENT)', () => {
const payload = buildProviderAccountingPayload({
...emptyProviderAccounting(),
paymentTypeIri: '/api/payment_types/3',
bankIri: '/api/banks/2',
}, true)
expect(payload.bank).toBe('/api/banks/2')
expect(payload.paymentType).toBe('/api/payment_types/3')
})
it('force la banque a null si non requise (hors VIREMENT)', () => {
const payload = buildProviderAccountingPayload({
...emptyProviderAccounting(),
bankIri: '/api/banks/2',
}, false)
expect(payload.bank).toBeNull()
})
})
describe('buildProviderRibPayload', () => {
it('omet les champs requis vides (NotBlank back joue sur le champ)', () => {
const payload = buildProviderRibPayload(emptyProviderRib())
expect(payload).not.toHaveProperty('label')
expect(payload).not.toHaveProperty('bic')
expect(payload).not.toHaveProperty('iban')
})
it('conserve les champs remplis', () => {
const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
})
})
})
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import {
buildProviderAddressPayload,
isProviderAddressValid,
} from '../providerAddress'
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
/**
* Helpers purs de l'onglet Adresse prestataire (ERP-143). RG-3.05 (>= 1 site) et
* construction du payload de sous-ressource (relations en IRI, requis vides omis,
* pas de type d'adresse / bennes / triage — difference M2).
*/
describe('providerAddress helpers', () => {
const SITE = '/api/sites/1'
const CAT = '/api/categories/7'
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
it('false sans site', () => {
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
expect(isProviderAddressValid(address)).toBe(false)
})
it('false sans categorie', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
expect(isProviderAddressValid(address)).toBe(false)
})
it('true avec au moins un site ET une categorie', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
expect(isProviderAddressValid(address)).toBe(true)
})
})
describe('buildProviderAddressPayload', () => {
it('mappe les relations en IRI et n\'embarque PAS type/bennes/triage (difference M2)', () => {
const payload = buildProviderAddressPayload({
...emptyProviderAddress(),
postalCode: '86100',
city: 'Châtellerault',
street: '1 rue du Test',
siteIris: [SITE],
categoryIris: [CAT],
contactIris: ['/api/provider_contacts/9'],
})
expect(payload).toEqual({
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '1 rue du Test',
streetComplement: null,
categories: [CAT],
sites: [SITE],
contacts: ['/api/provider_contacts/9'],
})
expect(payload).not.toHaveProperty('addressType')
expect(payload).not.toHaveProperty('bennes')
expect(payload).not.toHaveProperty('triageProvider')
})
it('omet les scalaires requis vides (NotBlank back joue sur le champ)', () => {
const payload = buildProviderAddressPayload({
...emptyProviderAddress(),
siteIris: [SITE],
categoryIris: [CAT],
})
expect(payload).not.toHaveProperty('postalCode')
expect(payload).not.toHaveProperty('city')
expect(payload).not.toHaveProperty('street')
// streetComplement n'est PAS requis -> reste present a null.
expect(payload).toHaveProperty('streetComplement', null)
})
})
})
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import {
buildProviderContactPayload,
hasAtLeastOneFilledContact,
isProviderContactBlank,
} from '../providerContact'
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
/**
* Helpers purs de l'onglet Contact prestataire (ERP-142). On verifie la
* definition de « bloc vide » (RG-3.04, alignee sur le back) et la construction
* du payload de sous-ressource.
*/
describe('providerContact helpers', () => {
describe('isProviderContactBlank (RG-3.04)', () => {
it('un bloc vierge est vide', () => {
expect(isProviderContactBlank(emptyProviderContact())).toBe(true)
})
it('un seul champ rempli parmi nom/prenom/fonction/tel/email suffit a le rendre non vide', () => {
for (const field of ['firstName', 'lastName', 'jobTitle', 'phonePrimary', 'email'] as const) {
const contact = { ...emptyProviderContact(), [field]: 'x' }
expect(isProviderContactBlank(contact)).toBe(false)
}
})
it('ignore les espaces (trim) — un champ blanc ne compte pas', () => {
expect(isProviderContactBlank({ ...emptyProviderContact(), lastName: ' ' })).toBe(true)
})
it('un 2e telephone seul NE suffit PAS (exclu, comme le back)', () => {
const contact = { ...emptyProviderContact(), hasSecondaryPhone: true, phoneSecondary: '0102030405' }
expect(isProviderContactBlank(contact)).toBe(true)
})
})
describe('hasAtLeastOneFilledContact (RG-3.12)', () => {
it('false si tous les blocs sont vides', () => {
expect(hasAtLeastOneFilledContact([emptyProviderContact(), emptyProviderContact()])).toBe(false)
})
it('true des qu\'un bloc porte une donnee', () => {
expect(hasAtLeastOneFilledContact([
emptyProviderContact(),
{ ...emptyProviderContact(), email: 'a@b.fr' },
])).toBe(true)
})
})
describe('buildProviderContactPayload', () => {
it('mappe les champs et envoie null pour les vides', () => {
const payload = buildProviderContactPayload({ ...emptyProviderContact(), lastName: 'Doe' })
expect(payload).toEqual({
firstName: null,
lastName: 'Doe',
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
})
})
it('n\'envoie le 2e telephone que si revele (max 2)', () => {
const masque = buildProviderContactPayload({
...emptyProviderContact(),
phoneSecondary: '0102030405',
hasSecondaryPhone: false,
})
expect(masque.phoneSecondary).toBeNull()
const revele = buildProviderContactPayload({
...emptyProviderContact(),
phoneSecondary: '0102030405',
hasSecondaryPhone: true,
})
expect(revele.phoneSecondary).toBe('0102030405')
})
})
})
@@ -0,0 +1,86 @@
/**
* Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144) —
* miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle
* ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07
* (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API.
*/
import type {
ProviderAccountingDraft,
ProviderRibFormDraft,
} from '~/modules/technique/types/providerForm'
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
const PAYMENT_TYPE_VIREMENT = 'VIREMENT'
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
const PAYMENT_TYPE_LCR = 'LCR'
/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */
const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
/** Vrai si une chaine porte au moins un caractere non-espace. */
function isFilled(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== ''
}
/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_VIREMENT
}
/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_LCR
}
/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */
export function isRibBlank(rib: ProviderRibFormDraft): boolean {
return ![rib.label, rib.bic, rib.iban].some(isFilled)
}
/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */
export function isRibComplete(rib: ProviderRibFormDraft): boolean {
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
}
/**
* Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations
* sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon
* `null` (le back vide la relation hors VIREMENT).
*/
export function buildProviderAccountingPayload(
accounting: ProviderAccountingDraft,
isBankRequired: boolean,
): Record<string, unknown> {
return {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired ? accounting.bankIri : null,
}
}
/**
* Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les
* champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur
* le champ.
*/
export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record<string, unknown> {
const payload: Record<string, unknown> = {
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}
for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
delete payload[key]
}
}
return payload
}
@@ -0,0 +1,50 @@
/**
* Helpers purs de l'onglet Adresse prestataire (M3 Technique, ERP-143) — miroir
* SIMPLIFIE de `supplierFormRules`/`supplierEdit` (M2), reimplemente cote module
* Technique (regle ABSOLUE n°1 : pas d'import inter-module). Testables sans Vue.
*/
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
/**
* Champs scalaires obligatoires non nullable cote back (NotBlank). A la creation
* (POST), on OMET du payload ceux qui sont vides pour que la 422 porte la
* violation NotBlank propre (sur le champ) plutot qu'une erreur de type.
*/
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
/**
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
*/
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
}
/**
* Payload de la sous-ressource addresses (groupe `provider:write:addresses`).
* Relations M2M en IRI. Les scalaires requis vides sont omis a la creation (cf.
* REQUIRED_NON_NULLABLE_KEYS).
*/
export function buildProviderAddressPayload(address: ProviderAddressFormDraft): Record<string, unknown> {
const payload: Record<string, unknown> = {
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: [...address.categoryIris],
sites: [...address.siteIris],
contacts: [...address.contactIris],
}
for (const key of REQUIRED_NON_NULLABLE_KEYS) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
delete payload[key]
}
}
return payload
}
@@ -0,0 +1,57 @@
/**
* Helpers purs de l'onglet Contact prestataire (M3 Technique, ERP-142) — miroir
* reduit de `supplierFormRules.ts` / `supplierEdit.ts` (M2). Testables sans Vue
* ni API : detection de bloc vide (RG-3.04) et construction du payload de
* sous-ressource contacts.
*/
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
/** Vrai si une chaine porte au moins un caractere non-espace. */
function isFilled(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== ''
}
/**
* RG-3.04 : un bloc Contact est VIDE tant qu'aucun des champs comptant pour la
* validite n'est rempli — prenom / nom / fonction / telephone principal / email.
*
* `phoneSecondary` est volontairement EXCLU : le back (CHECK
* `chk_provider_contact_name` + `ProviderContactProcessor`) ne le compte pas non
* plus, un bloc ne portant qu'un 2e numero reste invalide. Garder la meme
* definition cote front evite tout drift (un bloc « vide » front == bloc rejete
* back).
*/
export function isProviderContactBlank(contact: ProviderContactFormDraft): boolean {
return ![
contact.firstName,
contact.lastName,
contact.jobTitle,
contact.phonePrimary,
contact.email,
].some(isFilled)
}
/**
* RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
* bloc non vide (au moins un contact valide).
*/
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
return contacts.some(contact => !isProviderContactBlank(contact))
}
/**
* Payload de la sous-ressource contacts (groupe `provider:write:contacts`). Les
* chaines vides sont envoyees a null (le serveur normalise/trim de toute facon).
* `phoneSecondary` n'est envoye que si le 2e numero a ete revele (max 2 tel).
*/
export function buildProviderContactPayload(contact: ProviderContactFormDraft): Record<string, unknown> {
return {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
phonePrimary: contact.phonePrimary || null,
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null,
}
}