Compare commits

...

8 Commits

Author SHA1 Message Date
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
gitea-actions a6f01400ba chore: bump version to v0.1.116
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 40s
2026-06-15 09:02:10 +00:00
tristan d0e9f48983 feat(front) : page ajout prestataire + formulaire principal (ERP-141) (#103)
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-140 (#102).

## Périmètre ERP-141
Écran `/providers/new` — création par onglets + formulaire principal (POST).

- **Page** `modules/technique/pages/providers/new.vue` : en-tête + retour, formulaire principal (Nom, Catégorie, Site), barre d'onglets **Contact · Adresse · Comptabilité** (pas d'onglet Information ; Rapports/Échanges absents en création). Contenu des onglets = placeholders « À venir » (ERP-142→144).
- **`useProviderForm()`** : POST principal (groupe `provider:write:main`, IRIs catégories/sites), pré-check front RG-3.03 (≥1 site) / RG-3.09 (≥1 catégorie), 409 doublon (RG-3.10) inline, 422 mapping par champ via `useFormErrors`, orchestration des onglets (verrouillage + bascule auto sur Contact au succès), `patchProvider` (PATCH partiel mode strict pour les onglets à venir).
- **`useProviderReferentials()`** : catégories type PRESTATAIRE + sites (`?pagination=false`, Hydra).
- i18n `technique.providers.form/tab/toast`.

## Conformité
- `useApi()` uniquement, composants `Malio*`, aucun texte FR en dur, bouton « Valider » toujours actif + erreurs sous les champs (ERP-101).

## Vérifications
- Vitest : 402/402 (dont 9 nouveaux tests `useProviderForm`).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : page rendue, catégories filtrées PRESTATAIRE, sélecteur site, onglets désactivés avant validation, erreurs inline RG-3.03/3.09.

Reviewed-on: #103
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 08:59:39 +00:00
gitea-actions c1206fa29c chore: bump version to v0.1.115
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-15 08:51:28 +00:00
tristan 090ea5eb49 feat(front) : page repertoire prestataires (ERP-140) (#102)
Auto Tag Develop / tag (push) Successful in 9s
Page d'entree du pole Technique : repertoire prestataires (route /providers).

## Perimetre (ERP-140)
- Page `modules/technique/pages/providers/index.vue` (route /providers, titre i18n technique.providers.title).
- `MalioDataTable` branche sur `usePaginatedList<Provider>({ url: '/providers' })` : colonnes Nom / Categories / Site (badges) / Derniere activite (updatedAt, format JJ-MM-AAAA).
- Clic ligne -> /providers/{id} ; bouton + Ajouter -> /providers/new (gate technique.providers.manage).
- Drawer Filtres : recherche, categorie (type PRESTATAIRE), site, inclure archives. Etat 100% local (jamais dans l'URL).
- Bouton Exporter -> /api/providers/export.xlsx (memes filtres).
- Pagination standard 10/25/50.
- Composable `useProvidersRepository` + cles i18n `technique.providers.*`.

## Garde-fous
- `useApi()` uniquement, composants `Malio*`, pas de `<table>` brut, aucun texte FR en dur.
- Cloisonnement par site laisse au back.

## Tests
- `make nuxt-test` : 393/393 verts (dont 3 nouveaux sur useProvidersRepository : ciblage /providers, enveloppe Hydra, exclusion archives par defaut).
- ESLint clean.
- Note : `nuxi typecheck` non concluant dans l'env (develop produit deja ~303 erreurs d'auto-imports non resolus, independamment de cette branche). La page et le composable sont type-clean.

Reviewed-on: #102
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 08:51:19 +00:00
19 changed files with 2875 additions and 2 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ return [
// `technique` est desactive ou si l'user n'a pas la permission.
[
'label' => 'sidebar.technique.section',
'icon' => 'mdi:wrench-outline',
'icon' => 'mdi:account-convert-outline',
'items' => [
[
'label' => 'sidebar.technique.providers',
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.114'
app.version: '0.1.118'
+82
View File
@@ -366,6 +366,88 @@
}
}
},
"technique": {
"providers": {
"title": "Répertoire prestataires",
"add": "Ajouter",
"export": "Exporter",
"empty": "Aucun prestataire pour l'instant.",
"column": {
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
"tab": {
"contact": "Contact",
"address": "Adresse",
"accounting": "Comptabilité"
},
"form": {
"title": "Ajouter un prestataire",
"back": "Précédent",
"submit": "Valider",
"duplicateCompany": "Un prestataire portant ce nom de société existe déjà.",
"main": {
"companyName": "Nom du prestataire (Entreprise)",
"categories": "Catégorie",
"sites": "Site"
},
"errors": {
"siteRequired": "Sélectionnez au moins un site.",
"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."
},
"confirmDelete": {
"title": "Confirmer la suppression",
"cancel": "Annuler",
"confirm": "Supprimer",
"contact": "Supprimer ce contact ?",
"address": "Supprimer cette adresse ?"
}
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
"createSuccess": "Prestataire créé avec succès",
"updateSuccess": "Prestataire mis à jour avec succès"
}
}
},
"auth": {
"login": "Connexion",
"logout": "Deconnexion",
@@ -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('')
})
})
@@ -0,0 +1,408 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141).
*
* `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et
* l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la
* creation :
* - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie
* -> POST bloque, erreurs inline, aucun appel reseau.
* - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json
* + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact +
* reaffichage du nom normalise.
* - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName.
* - 422 -> mapping inline par champ (propertyPath).
* - Onglets : « Comptabilite » present uniquement avec accounting.view ;
* completeTab deverrouille/avance et signale le dernier onglet.
*/
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite).
const permState = vi.hoisted(() => ({ accountingView: false }))
vi.stubGlobal('useApi', () => ({
get: vi.fn(),
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useToast', () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}))
vi.stubGlobal('usePermissions', () => ({
can: (perm: string) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true,
}))
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 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', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
})
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
expect(form.mainLocked.value).toBe(false)
})
it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
form.main.siteIris = [SITE_86]
await form.submitMain()
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBeUndefined()
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
})
it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => {
mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' })
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(true)
expect(mockPost).toHaveBeenCalledTimes(1)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers')
expect(body).toEqual({
companyName: 'Maintenance Pro',
categories: [CAT_MAINT],
sites: [SITE_86],
})
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.providerId.value).toBe(42)
// RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur.
expect(form.main.companyName).toBe('MAINTENANCE PRO')
expect(form.mainLocked.value).toBe(true)
expect(form.activeTab.value).toBe('contact')
expect(form.unlockedIndex.value).toBe(0)
})
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
const form = useProviderForm()
form.main.companyName = ' '
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
expect(body).not.toHaveProperty('companyName')
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
})
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
const form = useProviderForm()
form.main.companyName = 'Doublon'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
expect(form.mainLocked.value).toBe(false)
})
it('422 : mappe les violations serveur inline par champ', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] },
},
})
const form = useProviderForm()
form.main.companyName = 'X'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.')
})
it('onglet Comptabilite : absent sans accounting.view, present avec', () => {
expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address'])
expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting'])
permState.accountingView = true
const form = useProviderForm()
expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting'])
})
it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => {
const form = useProviderForm()
// Contact -> Adresse (pas le dernier).
expect(form.completeTab('contact')).toBe(false)
expect(form.isValidated('contact')).toBe(true)
expect(form.activeTab.value).toBe('address')
expect(form.unlockedIndex.value).toBe(1)
// Adresse = dernier onglet remplissable (sans accounting.view) -> true.
expect(form.completeTab('address')).toBe(true)
expect(form.isValidated('address')).toBe(true)
})
it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => {
const form = useProviderForm()
await form.patchProvider({ siren: '123456789' })
expect(mockPatch).not.toHaveBeenCalled()
mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' })
form.main.companyName = 'Acme'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
await form.patchProvider({ siren: '123456789' })
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
})
})
describe('useProviderForm — onglet Contact (ERP-142)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = 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
})
/** 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)
})
})
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useProvidersRepository, type Provider } from '../useProvidersRepository'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests du repertoire prestataires (ERP-140).
*
* `useProvidersRepository` est une fine enveloppe de `usePaginatedList<Provider>`
* sur `/providers`. Les invariants generiques de pagination sont deja couverts
* par `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
* - la ressource ciblee est bien `/providers`
* - l'enveloppe Hydra (member / totalItems) est consommee
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination)
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
*/
describe('useProvidersRepository', () => {
beforeEach(() => {
mockApiGet.mockReset()
})
/** Une page de prestataires Hydra, avec categories[] et sites[] embarques. */
const PAGE: Provider[] = [
{
id: 1,
companyName: 'ACME MAINTENANCE',
categories: [{ code: 'MAINTENANCE_INDUSTRIELLE', name: 'Maintenance industrielle' }],
sites: [{ id: 4, name: 'Chatellerault', color: '#056CF2' }],
updatedAt: '2026-06-15T08:12:01+02:00',
isArchived: false,
},
]
it('cible /providers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/providers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(opts).toMatchObject({
toast: false,
headers: { Accept: 'application/ld+json' },
})
expect(repo.items.value).toEqual(PAGE)
expect(repo.totalItems.value).toBe(1)
})
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(query.includeArchived).toBeUndefined()
})
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ includeArchived: true })
expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.includeArchived).toBe(true)
})
})
@@ -0,0 +1,410 @@
import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { mapViolationsToRecord } from '~/shared/utils/api'
import {
emptyProviderAddress,
emptyProviderContact,
emptyProviderMain,
type ProviderAddressFormDraft,
type ProviderAddressResponse,
type ProviderContactFormDraft,
type ProviderContactResponse,
type ProviderMainDraft,
type ProviderMainResponse,
} from '~/modules/technique/types/providerForm'
import {
buildProviderContactPayload,
isProviderContactBlank,
} from '~/modules/technique/utils/forms/providerContact'
import {
buildProviderAddressPayload,
isProviderAddressValid,
} from '~/modules/technique/utils/forms/providerAddress'
/**
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
* miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en
* composable.
*
* Particularites M3 (cf. spec-front § « Ecran Ajouter ») :
* - PAS d'onglet « Information » : le formulaire principal est minimal (Nom +
* Categorie + Site).
* - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe
* `provider.sites`).
* - Creation incrementale par onglets (Contact · Adresse · Comptabilite) :
* POST principal puis PATCH partiels par groupe de serialisation
* (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets
* arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et
* l'orchestration des onglets.
*
* Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL.
*/
/**
* Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ;
* « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification.
* L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir
* (`technique.providers.accounting.view` — Admin, Compta).
*/
export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] {
return canAccountingView
? ['contact', 'address', 'accounting']
: ['contact', 'address']
}
export function useProviderForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
const { can } = usePermissions()
// Erreurs de validation par champ (ERP-101) du formulaire principal.
const mainErrors = useFormErrors()
// ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ──────────────────────────────────────────────────
const main = reactive<ProviderMainDraft>(emptyProviderMain())
// ── Onglets : ordre + gating progressif ───────────────────────────────────
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
const unlockedIndex = ref(-1)
const activeTab = ref<string>('contact')
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
function isValidated(key: string): boolean {
return validated[key] === true
}
function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/**
* Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09
* (>= 1 categorie). Pose les erreurs inline et retourne false si invalide.
* Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un
* aller-retour inutile et porte la garantie RG-3.03 cote front.
*/
function validateMainFront(): boolean {
let valid = true
if (main.siteIris.length === 0) {
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
valid = false
}
if (main.categoryIris.length === 0) {
mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired'))
valid = false
}
return valid
}
/**
* Payload du POST principal (groupe `provider:write:main`). `companyName` est
* omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur
* le champ plutot qu'une erreur de type. Les relations M2M partent en IRI.
*/
function buildMainPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = {
categories: [...main.categoryIris],
sites: [...main.siteIris],
}
if (main.companyName?.trim()) {
payload.companyName = main.companyName
}
return payload
}
/**
* POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09,
* puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er
* onglet et bascule sur « Contact ». Retourne true si cree, false sinon.
*/
async function submitMain(): Promise<boolean> {
if (mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
const created = await api.post<ProviderMainResponse>('/providers', buildMainPayload(), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
providerId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11).
main.companyName = created.companyName ?? main.companyName
mainLocked.value = true
unlockedIndex.value = 0
activeTab.value = tabKeys.value[0] ?? 'contact'
toast.success({ title: t('technique.providers.toast.createSuccess') })
return true
}
catch (error) {
// 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ;
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('technique.providers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('technique.providers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
}
return false
}
finally {
mainSubmitting.value = false
}
}
/**
* PATCH partiel du prestataire (mode strict : un seul groupe de serialisation
* par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires
* (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources
* (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas.
*/
async function patchProvider(payload: Record<string, unknown>): Promise<void> {
if (providerId.value === null) return
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
}
/**
* Marque un onglet valide (passe en lecture seule), deverrouille et avance a
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
* (creation terminee), false sinon.
*/
function completeTab(key: string): boolean {
validated[key] = true
const index = tabIndex(key)
const next = tabKeys.value[index + 1]
if (next === undefined) {
return true
}
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
activeTab.value = next
return false
}
/**
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
* on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le
* fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
* true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`.
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
const row = rows[index] as T
if (shouldSkip?.(row, index)) {
continue
}
try {
await saveRow(row, index)
}
catch (error) {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
}
else {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
// ── Onglet Contact (ERP-142) ──────────────────────────────────────────────
const contacts = ref<ProviderContactFormDraft[]>([emptyProviderContact()])
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » desactive tant que le dernier bloc est vide (RG-3.04).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && !isProviderContactBlank(last)
})
function addContact(): void {
if (canAddContact.value) {
contacts.value.push(emptyProviderContact())
}
}
function removeContact(index: number): void {
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
}
/**
* Valide l'onglet Contact : POST des nouveaux contacts sur
* /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id}
* (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc
* valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour
* declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un
* onglet vide. Retourne true si l'onglet a ete valide (avance/termine).
*/
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
if (providerId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
try {
const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = buildProviderContactPayload(contact)
if (contact.id === null) {
const created = await api.post<ProviderContactResponse>(
`/providers/${providerId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false })
}
},
onError,
contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact),
)
if (hasError) {
return false
}
completeTab('contact')
return true
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Adresse (ERP-143) ──────────────────────────────────────────────
const addresses = ref<ProviderAddressFormDraft[]>([emptyProviderAddress()])
// Erreurs 422 par ligne (alignees sur l'index du v-for).
const addressErrors = ref<Record<string, string>[]>([])
// « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas
// au moins un site ET une categorie (RG-3.05 / RG-3.09).
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isProviderAddressValid(last)
})
function addAddress(): void {
if (canAddAddress.value) {
addresses.value.push(emptyProviderAddress())
}
}
function removeAddress(index: number): void {
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
}
/**
* Valide l'onglet Adresse : POST des nouvelles adresses sur
* /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id}
* (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par
* ligne. Retourne true si l'onglet a ete valide (avance/termine).
*/
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
if (providerId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
try {
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
const body = buildProviderAddressPayload(address)
if (address.id === null) {
const created = await api.post<ProviderAddressResponse>(
`/providers/${providerId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/provider_addresses/${address.id}`, body, { toast: false })
}
},
onError,
)
if (hasError) {
return false
}
completeTab('address')
return true
}
finally {
tabSubmitting.value = false
}
}
return {
// etat
main,
providerId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
// onglets
canAccountingView,
tabKeys,
activeTab,
unlockedIndex,
validated,
isValidated,
// contacts
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
// adresses
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
// actions
validateMainFront,
buildMainPayload,
submitMain,
patchProvider,
completeTab,
submitRows,
}
}
@@ -0,0 +1,97 @@
import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects du formulaire
* principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) :
* categories (type PRESTATAIRE) et sites (86 / 17 / 82).
*
* Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire
* principal) seuls categories + sites sont necessaires. Les referentiels
* comptables (modes de TVA, delais/types de reglement, banques) seront charges
* par l'onglet Comptabilite (ERP-144).
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques entrees), avec l'en-tete
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
* quelle dans le payload POST (relations M2M).
*
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un
* echec (permission manquante, reseau) laisse simplement la liste vide.
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
interface HydraMember {
'@id': string
}
interface CategoryMember extends HydraMember {
code: string
name: string
}
interface SiteMember extends HydraMember {
name: string
postalCode: string
}
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useProviderReferentials() {
const api = useApi()
const categories = ref<RefOption[]>([])
const sites = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
url: string,
query: Record<string, string | string[]> = {},
): Promise<T[]> {
const res = await api.get<{ member?: T[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/** Charge en parallele les referentiels du formulaire principal (categories + sites). */
async function loadMain(): Promise<void> {
await Promise.allSettled([
// RG-3.09 : un prestataire ne porte que des categories de type
// PRESTATAIRE -> filtre cote API. Libelle affiche = `name`.
fetchAll<CategoryMember>('/categories', { typeCode: 'PRESTATAIRE' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }),
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
fetchAll<SiteMember>('/sites')
.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 })) }),
])
}
return {
categories,
sites,
countries,
loadMain,
}
}
@@ -0,0 +1,62 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
/**
* Site Starseed rattache DIRECTEMENT au prestataire (M2M `provider_site`,
* RG-3.03), tel qu'embarque en LISTE (groupe site:read) pour la colonne « Site »
* du Repertoire (badges colores).
*
* Difference M3 vs M2 : au M2 les sites venaient de l'agregat dedoublonne des
* adresses (`Supplier::getSites()`) ; ici c'est une relation directe portee par
* le formulaire principal (cf. spec-back M3 § 2.12).
*/
export interface ProviderSite {
id: number
name: string
color: string
}
/**
* Categorie (type PRESTATAIRE) rattachee au prestataire, embarquee en LISTE
* (groupe category:read). La colonne « Catégories » affiche le `name` (cohérence
* M1/M2 — libellé = `name`, pas `code`).
*/
export interface ProviderCategory {
code: string
name: string
}
/**
* Vue MINIMALE d'un prestataire pour le Repertoire (datatable). Volontairement
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-140).
*/
export interface Provider {
id: number
companyName: string
categories: ProviderCategory[]
sites: ProviderSite[]
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
updatedAt: string | null
isArchived: boolean
}
/**
* Repertoire prestataires (ERP-140) — simple enveloppe de `usePaginatedList<Provider>`
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
*
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
* par la page via `setFilters` du composable partage — la remise en page 1 est
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
*
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
* fonction de l'utilisateur — rien a filtrer cote front.
*
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
* `usePaginatedList`. Aucun reset au logout a gerer.
*/
export function useProvidersRepository() {
return usePaginatedList<Provider>({ url: '/providers' })
}
@@ -0,0 +1,438 @@
<template>
<div>
<PageHeader>
{{ t('technique.providers.title') }}
<template #actions>
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
<div class="flex items-center gap-8">
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('technique.providers.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList via useProvidersRepository :
pagination serveur, tri companyName ASC par defaut (cote back),
archives masques par defaut. Cloisonnement par site cote back. -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
table-class="table-fixed providers-table"
:empty-message="t('technique.providers.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Categories : libelles (name) separes par une virgule. -->
<template #cell-categories="{ item }">
{{ formatCategories(item) }}
</template>
<!-- Sites : badges colores (name + color), relation directe du prestataire. -->
<template #cell-sites="{ item }">
<span class="flex flex-wrap gap-1">
<span
v-for="site in (item.sites as ProviderSite[])"
:key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
:style="{ backgroundColor: site.color }"
>
{{ site.name }}
</span>
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt), format JJ-MM-AAAA. -->
<template #cell-lastActivity="{ item }">
{{ formatLastActivity(item) }}
</template>
</MalioDataTable>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('technique.providers.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Voir les résultats ». Meme pattern que le repertoire fournisseurs.
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('technique.providers.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche : nom entreprise + contact + email (param `search`). -->
<MalioAccordionItem :title="t('technique.providers.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Categories (type PRESTATAIRE) : cases a cocher (multi). Valeur = code stable. -->
<MalioAccordionItem :title="t('technique.providers.filters.categories')" value="categories">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in categoryOptions"
:id="`filter-category-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftCategoryCodes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
<MalioAccordionItem :title="t('technique.providers.filters.sites')" value="sites">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in siteOptions"
:id="`filter-site-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftSiteIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('technique.providers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('technique.providers.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Provider, ProviderSite } from '~/modules/technique/composables/useProvidersRepository'
interface FilterOption {
value: string
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('technique.providers.title') })
// Bouton « Ajouter » reserve a `manage` (POST /providers garde manage seul —
// Compta cree pas). « Exporter » et « Filtrer » suivent `view`.
const canManage = computed(() => can('technique.providers.manage'))
const canView = computed(() => can('technique.providers.view'))
const {
items: providers,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadProviders,
goToPage,
setItemsPerPage,
setFilters,
} = useProvidersRepository()
// Mappe les prestataires en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Provider. Meme pattern que fournisseurs.
const rows = computed(() => providers.value.map(provider => ({
id: provider.id,
companyName: provider.companyName,
categories: provider.categories,
sites: provider.sites,
updatedAt: provider.updatedAt,
})))
const columns = [
{ key: 'companyName', label: t('technique.providers.column.companyName') },
{ key: 'categories', label: t('technique.providers.column.categories') },
{ key: 'sites', label: t('technique.providers.column.sites') },
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
]
/** Libelles des categories du prestataire, separes par une virgule (name). */
function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Provider['categories']) ?? []
return categories.map(c => c.name).join(', ')
}
/**
* Derniere activite : date de derniere modification de la fiche (updatedAt,
* expose en liste via default:read). Format court francais JJ-MM-AAAA (tirets,
* cf. spec-front M3 § Datatable).
*/
function formatLastActivity(item: Record<string, unknown>): string {
const value = item.updatedAt as string | null | undefined
if (!value) {
return ''
}
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = date.getFullYear()
return `${day}-${month}-${year}`
}
/** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/providers/${item.id}`)
}
function goToCreate(): void {
router.push('/providers/new')
}
// ── Filtres (drawer) ────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern repertoire fournisseurs) :
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
const siteOptions = ref<FilterOption[]>([])
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('technique.providers.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
// reouverture reflete les filtres actifs.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
filterDrawerOpen.value = true
}
function toggleCategory(code: string, selected: boolean): void {
draftCategoryCodes.value = selected
? [...draftCategoryCodes.value, code]
: draftCategoryCodes.value.filter(c => c !== code)
}
function toggleSite(id: string, selected: boolean): void {
draftSiteIds.value = selected
? [...draftSiteIds.value, id]
: draftSiteIds.value.filter(s => s !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
* Les filtres vides sont omis pour une query propre.
*/
function buildFilterPayload(): Record<string, string | string[] | boolean> {
const payload: Record<string, string | string[] | boolean> = {}
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
return payload
}
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
setFilters({}, { replace: true })
}
/** Charge les referentiels du drawer (categories PRESTATAIRE + sites) via ?pagination=false. */
async function loadFilterOptions(): Promise<void> {
const [cats, sites] = await Promise.all([
api.get<{ member?: Array<{ code: string, name: string }> }>(
'/categories',
// Taxonomie multi-types : le filtre du repertoire prestataires ne
// propose que les categories de type PRESTATAIRE.
{ pagination: 'false', typeCode: 'PRESTATAIRE' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
api.get<{ member?: Array<{ id: number, name: string }> }>(
'/sites',
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
])
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
}
// ── Export XLSX ─────────────────────────────────────────────────────────────
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
// l'utilisateur a accounting.view (gere cote back).
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage — meme approche que
// l'export fournisseurs.
const blob = await api.get<Blob>('/providers/export.xlsx', buildFilterPayload(), {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'repertoire-prestataires.xlsx')
}
catch {
toast.error({
title: t('technique.providers.toast.error'),
message: t('technique.providers.toast.exportError'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadProviders()
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
// l'utilisateur perd juste les options de filtre.
loadFilterOptions().catch(() => {
categoryOptions.value = []
siteOptions.value = []
})
})
</script>
<style scoped>
/*
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
* couleurs/tailles (qui restent sur les defauts Malio).
*/
:deep(.providers-table tbody td:nth-child(3)) {
padding-top: 8px;
padding-bottom: 8px;
}
</style>
@@ -0,0 +1,324 @@
<template>
<div>
<!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('technique.providers.form.title') }}</h1>
</div>
<!-- Formulaire principal (pre-onglets)
Sans validation de ce bloc, les onglets restent inaccessibles. Au
succes du POST, les champs passent en lecture seule et on bascule
automatiquement sur l'onglet Contact (PAS d'onglet Information au M3).
Selecteur de site present ici (RG-3.03, relation directe). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelectCheckbox
:model-value="main.siteIris"
:options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
/>
</div>
<div v-if="!mainLocked" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
<!-- ── Onglets a validation incrementale ─────────────────────────────
Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux
tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="index > 0"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
@click="onSubmitContacts"
/>
</div>
</div>
</template>
<!-- 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>
<template v-if="canAccountingView" #accounting><ComingSoonPlaceholder /></template>
</MalioTabList>
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
const { t } = useI18n()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('technique.providers.form.title') })
// Gating de la route : la creation est reservee a `manage` (POST /providers garde
// manage seul — Compta ne cree pas). Compta (accounting seul) et Usine sont
// rediriges vers le repertoire.
if (!can('technique.providers.manage')) {
await navigateTo('/providers')
}
const referentials = useProviderReferentials()
const {
main,
mainLocked,
mainSubmitting,
mainErrors,
canAccountingView,
tabKeys,
activeTab,
unlockedIndex,
submitMain,
tabSubmitting,
isValidated,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
} = useProviderForm()
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
function goBack(): void {
router.push('/providers')
}
/**
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API. Retourne
* TOUJOURS une chaine (le composant de toast plante sur `undefined`).
*/
function apiErrorMessage(error: unknown): string {
const data = (error as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
// ── Onglet Contact ──────────────────────────────────────────────────────────
/** Valide l'onglet Contact ; toast de succes si l'onglet a ete finalise. */
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
function askRemoveContact(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
}
// ── 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))
}
// ── Modal de confirmation generique ─────────────────────────────────────────
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
// Icone (Iconify) affichee dans l'onglet, par cle.
const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
accounting: 'mdi:bank-circle-outline',
}
// Onglets desactives tant que le formulaire principal n'est pas valide
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`technique.providers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadMain().catch(() => {})
})
</script>
@@ -0,0 +1,126 @@
/**
* Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique).
*
* Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet
* Information, et porte en plus un selecteur de site SUR le formulaire principal
* (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse).
*
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
* DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet
* Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent.
*
* Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`),
* envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 :
* `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`).
*/
/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */
export interface ProviderMainDraft {
/** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */
companyName: string | null
/** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */
categoryIris: string[]
/** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */
siteIris: string[]
}
/** Fabrique un formulaire principal vierge. */
export function emptyProviderMain(): ProviderMainDraft {
return {
companyName: null,
categoryIris: [],
siteIris: [],
}
}
/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */
export interface ProviderMainResponse {
id: number
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
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
}
@@ -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,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,
}
}