feat(front) : referentiels + blocs reutilisables contact/adresse/placeholder (ERP-63)

- useClientReferentials : chargement des selects (categories, sites, modes TVA,
  delais/types de reglement, banques) en ?pagination=false + listes
  distributeurs/courtiers via ?categoryCode=DISTRIBUTEUR|COURTIER.
- ClientContactBlock / ClientAddressBlock : blocs reutilises par 1.11/1.12.
  L'adresse gere la saisie assistee BAN (via le stub) avec bascule en mode
  degrade (ville/adresse en saisie libre).
- TabPlaceholderBlank : frame vide des onglets non implementes.
This commit is contained in:
2026-06-02 17:25:09 +02:00
parent 1193832505
commit 1bc432e8fa
4 changed files with 537 additions and 0 deletions
@@ -0,0 +1,280 @@
<template>
<div class="rounded-md border border-neutral-200 bg-white p-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-bold">{{ title }}</h3>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:trash-can-outline"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation
(RG-1.06/07/08). On masque l'option incompatible selon l'etat. -->
<div class="mb-4 flex flex-wrap gap-6">
<MalioCheckbox
v-if="canSelectProspect(model)"
:model-value="model.isProspect"
:label="t('commercial.clients.form.address.prospect')"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
/>
<MalioCheckbox
v-if="canSelectDeliveryOrBilling(model)"
:model-value="model.isDelivery"
:label="t('commercial.clients.form.address.delivery')"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
/>
<MalioCheckbox
v-if="canSelectDeliveryOrBilling(model)"
:model-value="model.isBilling"
:label="t('commercial.clients.form.address.billing')"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:disabled="readonly"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:disabled="readonly"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
degrade (service indisponible), bascule en saisie libre. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:disabled="readonly"
empty-option-label=""
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse : saisie assistee (BAN) ou libre en mode degrade. -->
<MalioInputAutocomplete
v-if="!degraded"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
@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('commercial.clients.form.address.street')"
:readonly="readonly"
@update:model-value="(v: string) => update('street', v)"
/>
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:readonly="readonly"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.clients.form.address.sites')"
:display-tag="true"
:disabled="readonly"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<MalioSelectCheckbox
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:disabled="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Email de facturation : visible/obligatoire seulement si Facturation
est coche (RG-1.11). -->
<MalioInputText
v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')"
:required="true"
:readonly="readonly"
@update:model-value="(v: string) => update('billingEmail', v)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
applyProspectExclusivity,
canSelectDeliveryOrBilling,
canSelectProspect,
isBillingEmailRequired,
type AddressFlagsDraft,
} from '~/modules/commercial/utils/clientFormRules'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: AddressFormDraft
title: string
/** Categories autorisees sur une adresse (DISTRIBUTEUR/COURTIER exclus, RG-1.29). */
categoryOptions: CategoryOption[]
/** 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
}>()
const emit = defineEmits<{
'update:modelValue': [value: AddressFormDraft]
'remove': []
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
'degraded': []
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue)
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
const degraded = ref(false)
const cityOptions = ref<RefOption[]>([])
const addressOptions = ref<RefOption[]>([])
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 AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */
function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void {
const flags = applyProspectExclusivity(
{ isProspect: model.value.isProspect, isDelivery: model.value.isDelivery, isBilling: model.value.isBilling },
field,
value,
)
emit('update:modelValue', { ...props.modelValue, ...flags })
}
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
function enterDegraded(): void {
if (!degraded.value) {
degraded.value = true
emit('degraded')
}
}
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
if (degraded.value) {
return
}
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
}
try {
const suggestions = await autocomplete.searchCity(digits)
cityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
}
catch {
enterDegraded()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
if (degraded.value) {
return
}
addressLoading.value = true
try {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
enterDegraded()
}
finally {
addressLoading.value = false
}
}
/**
* Selection d'une suggestion d'adresse → remplit rue + ville + CP.
* Le type d'option suit le contrat MalioInputAutocomplete ({ label, value }).
*/
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,102 @@
<template>
<div class="rounded-md border border-neutral-200 bg-white p-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-bold">{{ title }}</h3>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee
si non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule. -->
<!-- ariaLabel passe via v-bind objet : la prop du composant est
camelCase (aria-* serait traite en attribut HTML, jamais en prop). -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:trash-can-outline"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
@click="$emit('remove')"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<MalioInputText
:model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')"
:readonly="readonly"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')"
:readonly="readonly"
@update:model-value="(v: string) => update('firstName', v)"
/>
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
<MalioInputEmail
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
:readonly="readonly"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
:model-value="model.phonePrimary"
:label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary"
:label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: ContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: ContactFormDraft]
'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 ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Revele le 2e numero (RG-1.02/1.20 : max 1 secondaire, le « + » disparait). */
function revealSecondaryPhone(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
}
</script>
@@ -0,0 +1,14 @@
<template>
<!--
Placeholder des onglets non encore implementes (Transport, Statistiques,
Rapports, Echanges). Frame vide blanche : aucun champ, aucun bouton,
aucun message « En cours » (decision Tristan 28/05). L'orchestrateur passe
automatiquement a l'onglet suivant ce composant n'est qu'une coquille
visuelle reutilisee par 1.11/1.12.
-->
<div class="min-h-[240px] rounded-md bg-white" />
</template>
<script setup lang="ts">
// Composant purement presentationnel : aucune prop, aucun event.
</script>
@@ -0,0 +1,141 @@
import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
* reglement, banques, et les listes distributeurs / courtiers.
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
* l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir
* l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`)
* pour pouvoir etre renvoyees telles quelles dans les payloads POST/PATCH
* (relations ManyToOne / ManyToMany).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */
export interface RefOption {
value: string
label: string
}
/** Option de type de reglement enrichie de son code stable (RG-1.12 / RG-1.13). */
export interface PaymentTypeOption extends RefOption {
code: string
}
/** Option de categorie enrichie de son code stable (filtrage RG-1.29 cote adresse). */
export interface CategoryOption extends RefOption {
code: string
}
/** Option de client (distributeur / courtier) — value = IRI du client lie. */
export type ClientOption = RefOption
interface HydraMember {
'@id': string
}
interface CategoryMember extends HydraMember {
code: string
name: string
}
interface SiteMember extends HydraMember {
name: string
}
interface ReferentialMember extends HydraMember {
code: string
label: string
}
interface ClientMember extends HydraMember {
companyName: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useClientReferentials() {
const api = useApi()
const categories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
const distributors = ref<ClientOption[]>([])
const brokers = ref<ClientOption[]>([])
/** 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 communs (hors distributeurs/courtiers,
* charges a la demande selon la relation choisie). Les selects compta ne sont
* pertinents que si l'utilisateur a acces a l'onglet, mais le cout est
* negligeable et simplifie l'orchestration.
*/
async function loadCommon(): Promise<void> {
const [cats, sitesList, tva, delays, types, banksList] = await Promise.all([
fetchAll<CategoryMember>('/categories'),
fetchAll<SiteMember>('/sites'),
fetchAll<ReferentialMember>('/tva_modes'),
fetchAll<ReferentialMember>('/payment_delays'),
fetchAll<ReferentialMember>('/payment_types'),
fetchAll<ReferentialMember>('/banks'),
])
categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code }))
sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name }))
tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label }))
paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label }))
paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code }))
banks.value = banksList.map(b => ({ value: b['@id'], label: b.label }))
}
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
async function loadDistributors(): Promise<void> {
if (distributors.value.length > 0) {
return
}
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'DISTRIBUTEUR' })
distributors.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
}
/** Liste des clients pouvant etre choisis comme courtier (code COURTIER). */
async function loadBrokers(): Promise<void> {
if (brokers.value.length > 0) {
return
}
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'COURTIER' })
brokers.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
}
return {
categories,
sites,
tvaModes,
paymentDelays,
paymentTypes,
banks,
distributors,
brokers,
loadCommon,
loadDistributors,
loadBrokers,
}
}