Compare commits

..

3 Commits

Author SHA1 Message Date
tristan 29ee4e9fd0 feat(front) : page ajouter un client (formulaire principal + onglets) (ERP-63)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 2m37s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m11s
- pages/clients/new.vue : route /clients/new gatee par commercial.clients.manage.
  Formulaire principal (POST /clients) puis validation incrementale des onglets
  via MalioTabList ; un onglet valide passe en lecture seule, passage automatique
  au suivant. Information (PATCH groupe information), Contact/Adresse (sous-ressources
  contacts/addresses), Comptabilite (PATCH scalaires accounting + POST ribs, gate
  accounting.view/manage), onglets coquille auto-avances. Mode strict RG-1.28 :
  chaque requete ne porte que les champs de son groupe.
- i18n : cles commercial.clients.form.* (formulaire, onglets, confirmation, toasts).

RG-1.04 (Information obligatoire pour la Commerciale) : non miroitee cote front
(roles = IRIs dans /api/me, Bureau == Commerciale en permissions) ; appliquee de
maniere fiable par le back. A rebrancher quand un code de role sera expose.
2026-06-02 17:25:09 +02:00
tristan 1bc432e8fa 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.
2026-06-02 17:25:09 +02:00
tristan 1193832505 feat(front) : regles metier + types + stub autocomplete creation client (ERP-63)
- clientFormRules : RG-1.05/1.14 (contacts), RG-1.06/07/08/11 (exclusivite
  adresse + email facturation), RG-1.12/1.13 (banque/RIB selon type de
  reglement), construction des onglets (gating Comptabilite). 18 tests Vitest.
- types/clientForm : brouillons locaux (contact/adresse/RIB) + fabriques.
- useAddressAutocomplete : STUB ERP-63 conforme a la signature ERP-66 (mode
  degrade), a remplacer par l'implementation BAN d'ERP-66.

Note : RG-1.04 (Information obligatoire pour la Commerciale) volontairement non
miroitee cote front (le payload /api/me ne porte pas le code de role ; Bureau et
Commerciale partagent les memes permissions). Le back l'applique de maniere fiable.
2026-06-02 17:20:19 +02:00
10 changed files with 1957 additions and 0 deletions
+84
View File
@@ -99,6 +99,90 @@
"phoneFormat": "Format de téléphone invalide (attendu : XX XX XX XX XX).",
"emailFormat": "Format d'email invalide.",
"addressCategoryForbidden": "Une catégorie « Distributeur » ou « Courtier » ne peut pas qualifier une adresse."
},
"form": {
"title": "Ajouter un client",
"submit": "Valider",
"duplicateCompany": "Un client portant ce nom de société existe déjà.",
"main": {
"companyName": "Nom de l'entreprise",
"firstName": "Prénom",
"lastName": "Nom",
"email": "Email",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"categories": "Catégories",
"relation": "Relation",
"relationNone": "Aucune",
"relationDistributor": "Distributeur",
"relationBroker": "Courtier",
"distributorName": "Nom du distributeur",
"brokerName": "Nom du courtier",
"triageService": "Prestation de triage"
},
"information": {
"description": "Description",
"competitors": "Concurrents",
"foundedAt": "Date de création",
"employeesCount": "Nombre de salariés",
"revenueAmount": "Chiffre d'affaires",
"profitAmount": "Résultat",
"directorName": "Dirigeant"
},
"contact": {
"title": "Contact {n}",
"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": {
"title": "Adresse {n}",
"prospect": "Prospect",
"delivery": "Adresse de livraison",
"billing": "Facturation",
"categories": "Catégorie",
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
"street": "Adresse",
"streetComplement": "Adresse complémentaire",
"sites": "Sites Starseed",
"contacts": "Contact(s) rattaché(s)",
"billingEmail": "Email de facturation",
"remove": "Supprimer l'adresse",
"add": "Nouvelle adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
"nTva": "N° de TVA",
"paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement",
"bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé",
"ribBic": "BIC",
"ribIban": "IBAN",
"addRib": "Ajouter un RIB",
"removeRib": "Supprimer le RIB"
},
"confirmDelete": {
"title": "Confirmer la suppression",
"contact": "Supprimer ce contact ?",
"address": "Supprimer cette adresse ?",
"rib": "Supprimer ce RIB ?",
"cancel": "Annuler",
"confirm": "Confirmer"
}
}
}
},
@@ -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,
}
}
@@ -0,0 +1,891 @@
<template>
<div>
<PageHeader>{{ t('commercial.clients.form.title') }}</PageHeader>
<!-- 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 Information. -->
<section class="mb-8 rounded-md border border-neutral-200 bg-white p-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<MalioInputText
v-model="main.companyName"
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="mainLocked"
/>
<MalioInputEmail
v-model="main.email"
:label="t('commercial.clients.form.main.email')"
:required="true"
:readonly="mainLocked"
/>
<MalioInputText
v-model="main.lastName"
:label="t('commercial.clients.form.main.lastName')"
:readonly="mainLocked"
/>
<MalioInputText
v-model="main.firstName"
:label="t('commercial.clients.form.main.firstName')"
:readonly="mainLocked"
/>
<MalioInputPhone
v-model="main.phonePrimary"
:label="t('commercial.clients.form.main.phonePrimary')"
:mask="PHONE_MASK"
:required="true"
:readonly="mainLocked"
:addable="!main.hasSecondaryPhone && !mainLocked"
:add-button-label="t('commercial.clients.form.main.addPhone')"
@add="main.hasSecondaryPhone = true"
/>
<MalioInputPhone
v-if="main.hasSecondaryPhone"
v-model="main.phoneSecondary"
:label="t('commercial.clients.form.main.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="mainLocked"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:disabled="mainLocked"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelect
:model-value="main.relationType"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:disabled="mainLocked"
@update:model-value="onRelationChange"
/>
<MalioSelect
v-if="main.relationType === 'distributeur'"
:model-value="main.distributorIri"
:options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')"
:disabled="mainLocked"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/>
<MalioSelect
v-if="main.relationType === 'courtier'"
:model-value="main.brokerIri"
:options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')"
:disabled="mainLocked"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/>
</div>
<div class="mt-4 flex items-center gap-6">
<MalioCheckbox
v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')"
:readonly="mainLocked"
/>
<MalioButton
v-if="!mainLocked"
class="ml-auto"
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!isMainValid || mainSubmitting"
@click="submitMain"
/>
</div>
</section>
<!-- ── Onglets a validation incrementale ─────────────────────────────-->
<MalioTabList v-model="activeTab" :tabs="tabs">
<!-- Onglet Information -->
<template #information>
<div class="rounded-md border border-neutral-200 bg-white p-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<MalioInputTextArea
v-model="information.description"
:label="t('commercial.clients.form.information.description')"
:disabled="isValidated('information')"
group-class="md:col-span-2"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')"
/>
<MalioInputNumber
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
min="0"
:disabled="isValidated('information')"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:disabled="isValidated('information')"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:disabled="isValidated('information')"
/>
</div>
<div v-if="!isValidated('information')" class="mt-4 flex justify-end">
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting"
@click="submitInformation"
/>
</div>
</div>
</template>
<!-- Onglet Contact -->
<template #contact>
<div class="flex flex-col gap-4">
<ClientContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:readonly="isValidated('contact')"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!isValidated('contact')" class="flex items-center justify-between">
<MalioButton
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
:label="t('commercial.clients.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateContacts || tabSubmitting"
@click="submitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresse -->
<template #address>
<div class="flex flex-col gap-4">
<ClientAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:category-options="addressCategoryOptions"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('address')"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!isValidated('address')" class="flex items-center justify-between">
<MalioButton
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
:label="t('commercial.clients.form.address.add')"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateAddresses || tabSubmitting"
@click="submitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting>
<div class="flex flex-col gap-4">
<div class="rounded-md border border-neutral-200 bg-white p-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly"
empty-option-label=""
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly"
empty-option-label=""
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly"
empty-option-label=""
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly"
empty-option-label=""
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR. -->
<div
v-for="(rib, index) in ribs"
:key="index"
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">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h3>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="!accountingReadonly"
icon="mdi:trash-can-outline"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex items-center justify-between">
<MalioButton
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateAccounting || tabSubmitting"
@click="submitAccounting"
/>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide, passage automatique. -->
<template #transport><TabPlaceholderBlank /></template>
<template #statistics><TabPlaceholderBlank /></template>
<template #reports><TabPlaceholderBlank /></template>
<template #exchanges><TabPlaceholderBlank /></template>
</MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('commercial.clients.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('commercial.clients.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import {
buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType,
isBillingEmailRequired,
isContactNamed,
isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/clientFormRules'
import {
emptyAddress,
emptyContact,
emptyRib,
type AddressFormDraft,
type ContactFormDraft,
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone'
// Masques de saisie (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########'
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
const { t } = useI18n()
const api = useApi()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('commercial.clients.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
// seul) et Usine sont rediriges vers le repertoire (cf. §0 du ticket).
if (!can('commercial.clients.manage')) {
await navigateTo('/clients')
}
const canAccountingView = computed(() => can('commercial.clients.accounting.view'))
const canAccountingManage = computed(() => can('commercial.clients.accounting.manage'))
const referentials = useClientReferentials()
// ── Etat du client cree ────────────────────────────────────────────────────
const clientId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ────────────────────────────────────────────────────
const main = reactive({
companyName: null as string | null,
firstName: null as string | null,
lastName: null as string | null,
email: null as string | null,
phonePrimary: null as string | null,
phoneSecondary: null as string | null,
hasSecondaryPhone: false,
categoryIris: [] as string[],
relationType: 'aucun' as 'aucun' | 'distributeur' | 'courtier',
distributorIri: null as string | null,
brokerIri: null as string | null,
triageService: false,
})
const relationOptions = computed<RefOption[]>(() => [
{ value: 'aucun', label: t('commercial.clients.form.main.relationNone') },
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
// RG-1.01 : firstName OU lastName, + companyName / email / phonePrimary requis.
const isMainValid = computed(() => {
const filled = (v: string | null) => v !== null && v.trim() !== ''
return filled(main.companyName)
&& filled(main.email)
&& filled(main.phonePrimary)
&& (filled(main.firstName) || filled(main.lastName))
})
async function onRelationChange(value: string | number | null): Promise<void> {
const relation = String(value ?? 'aucun') as typeof main.relationType
main.relationType = relation
// Reinitialise la FK non concernee (une seule remplie a la fois, RG-1.03).
if (relation !== 'distributeur') main.distributorIri = null
if (relation !== 'courtier') main.brokerIri = null
if (relation === 'distributeur') await referentials.loadDistributors()
if (relation === 'courtier') await referentials.loadBrokers()
}
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true
try {
const payload: Record<string, unknown> = {
companyName: main.companyName,
firstName: main.firstName || null,
lastName: main.lastName || null,
email: main.email,
phonePrimary: main.phonePrimary,
phoneSecondary: main.hasSecondaryPhone ? (main.phoneSecondary || null) : null,
categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}
const created = await api.post<ClientResponse>('/clients', payload, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
clientId.value = created.id
// Reaffiche les valeurs normalisees renvoyees par le serveur.
main.companyName = created.companyName ?? main.companyName
main.firstName = created.firstName ?? null
main.lastName = created.lastName ?? null
main.email = created.email ?? main.email
main.phonePrimary = formatPhoneFR(created.phonePrimary) || main.phonePrimary
main.phoneSecondary = formatPhoneFR(created.phoneSecondary) || null
// Pre-remplit le 1er contact a partir du formulaire principal (editable).
prefillFirstContact()
mainLocked.value = true
unlockedIndex.value = 0
activeTab.value = 'information'
toast.success({ title: t('commercial.clients.toast.createSuccess') })
}
catch (error) {
// 409 = doublon nom de societe (RG d'unicite) → message explicite.
const status = (error as { response?: { status?: number } })?.response?.status
toast.error({
title: t('commercial.clients.toast.error'),
message: status === 409 ? t('commercial.clients.form.duplicateCompany') : undefined,
})
}
finally {
mainSubmitting.value = false
}
}
// ── Onglets : ordre + gating progressif ─────────────────────────────────────
const activeTab = ref('information')
// Index du dernier onglet deverrouille (-1 tant que le client n'est pas cree).
const unlockedIndex = ref(-1)
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`commercial.clients.tab.${key}`),
disabled: index > unlockedIndex.value,
})))
function isValidated(key: string): boolean {
return validated[key] === true
}
function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */
function completeTab(key: string): void {
validated[key] = true
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
}
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
watch(activeTab, (key) => {
if ((CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key)) {
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
}
})
// ── Onglet Information ──────────────────────────────────────────────────────
const information = reactive({
description: null as string | null,
competitors: null as string | null,
foundedAt: null as string | null,
employeesCount: null as string | null,
revenueAmount: null as string | null,
profitAmount: null as string | null,
directorName: null as string | null,
})
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
await api.patch(`/clients/${clientId.value}`, {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
directorName: information.directorName || null,
}, { toast: false })
completeTab('information')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch {
toast.error({ title: t('commercial.clients.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Contact ──────────────────────────────────────────────────────────
const contacts = ref<ContactFormDraft[]>([emptyContact()])
/** Pre-remplit le 1er contact depuis le formulaire principal (apres creation). */
function prefillFirstContact(): void {
const first = contacts.value[0]
if (!first) return
first.lastName = main.lastName
first.firstName = main.firstName
first.email = main.email
first.phonePrimary = main.phonePrimary
}
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isContactNamed(last)
})
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
contacts.value.splice(index, 1)
})
}
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true
try {
for (const contact of contacts.value) {
// On ignore les blocs totalement vides (ni nom ni prenom).
if (!isContactNamed(contact)) continue
const body = {
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,
}
if (contact.id === null) {
const created = await api.post<ContactResponse>(
`/clients/${clientId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
}
completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch {
toast.error({ title: t('commercial.clients.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Adresse ──────────────────────────────────────────────────────────
const addresses = ref<AddressFormDraft[]>([emptyAddress()])
const addressDegradedNotified = ref(false)
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
const addressCategoryOptions = computed(() =>
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
)
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
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 ?? ''),
})),
)
// France par defaut au M1 (liste pays minimale ; a etendre quand le besoin viendra).
const countryOptions: RefOption[] = [{ value: 'France', label: 'France' }]
// RG-1.10 (>= 1 site) + RG-1.11 (email facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() =>
addresses.value.length > 0
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail)
}),
)
function addAddress(): void {
addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
addresses.value.splice(index, 1)
})
}
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade. */
function onAddressDegraded(): void {
if (addressDegradedNotified.value) return
addressDegradedNotified.value = true
toast.warning({
title: t('commercial.clients.toast.error'),
message: t('commercial.clients.form.address.degraded'),
})
}
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true
try {
for (const address of addresses.value) {
const body = {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
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,
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
}
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
}
completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch {
toast.error({ title: t('commercial.clients.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Comptabilite ─────────────────────────────────────────────────────
const accounting = reactive({
siren: null as string | null,
accountNumber: null as string | null,
tvaModeIri: null as string | null,
nTva: null as string | null,
paymentDelayIri: null as string | null,
paymentTypeIri: null as string | null,
bankIri: null as string | null,
})
const ribs = ref<RibFormDraft[]>([])
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
// Code du type de reglement selectionne (pour RG-1.12 / RG-1.13).
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
if (!isBankRequired.value) accounting.bankIri = null
}
function ribIsComplete(rib: RibFormDraft): boolean {
const filled = (v: string | null) => v !== null && v.trim() !== ''
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
}
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
const canValidateAccounting = computed(() => {
if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true
})
function addRib(): void {
ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1)
})
}
/**
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting)
* PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict
* RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back).
*/
async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true
try {
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
for (const rib of ribs.value) {
if (!ribIsComplete(rib)) continue
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
{ label: rib.label, bic: rib.bic, iban: rib.iban },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
}
}
completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch {
toast.error({ title: t('commercial.clients.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// ── 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
}
// ── Types de reponse API ────────────────────────────────────────────────────
interface ClientResponse {
id: number
companyName: string | null
firstName: string | null
lastName: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
}
interface ContactResponse {
'@id'?: string
id: number
}
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {})
})
</script>
@@ -0,0 +1,98 @@
/**
* Types « brouillon » de l'ecran « Ajouter un client » (M1 Commercial).
*
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
* DTO de l'API : elles portent en plus des champs purement UI (`hasSecondaryPhone`)
* et l'`iri` Hydra des entites creees (necessaire pour rattacher une adresse a
* des contacts deja persistes, M2M). Partage par la page et les blocs reutilisables
* `ClientContactBlock` / `ClientAddressBlock` (reutilises par 1.11/1.12).
*/
/** Un contact du client (onglet Contact). */
export interface ContactFormDraft {
/** Id serveur une fois le contact cree (null tant que non persiste). */
id: number | null
/** IRI Hydra du contact cree — utilise pour le rattachement M2M cote adresse. */
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 « + ». */
hasSecondaryPhone: boolean
}
/** Une adresse du client (onglet Adresse). */
export interface AddressFormDraft {
id: number | null
isProspect: boolean
isDelivery: boolean
isBilling: boolean
country: string
postalCode: string | null
city: string | null
street: string | null
streetComplement: string | null
/** IRI des categories rattachees (hors DISTRIBUTEUR/COURTIER — RG-1.29). */
categoryIris: string[]
/** IRI des sites Starseed rattaches (>= 1 obligatoire — RG-1.10). */
siteIris: string[]
/** IRI des contacts rattaches (= blocs Contact deja crees). */
contactIris: string[]
/** Email de facturation (obligatoire si isBilling — RG-1.11). */
billingEmail: string | null
}
/** Un RIB du client (onglet Comptabilite). */
export interface RibFormDraft {
id: number | null
label: string | null
bic: string | null
iban: string | null
}
/** Fabrique un contact vierge. */
export function emptyContact(): ContactFormDraft {
return {
id: null,
iri: null,
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
hasSecondaryPhone: false,
}
}
/** Fabrique une adresse vierge (pays prerempli « France »). */
export function emptyAddress(): AddressFormDraft {
return {
id: null,
isProspect: false,
isDelivery: false,
isBilling: false,
country: 'France',
postalCode: null,
city: null,
street: null,
streetComplement: null,
categoryIris: [],
siteIris: [],
contactIris: [],
billingEmail: null,
}
}
/** Fabrique un RIB vierge. */
export function emptyRib(): RibFormDraft {
return {
id: null,
label: null,
bic: null,
iban: null,
}
}
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import {
applyProspectExclusivity,
buildClientFormTabKeys,
canSelectDeliveryOrBilling,
canSelectProspect,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType,
isBillingEmailRequired,
isContactNamed,
isRibRequiredForPaymentType,
type ContactDraft,
} from '../clientFormRules'
describe('buildClientFormTabKeys (gating onglet Comptabilite)', () => {
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
expect(buildClientFormTabKeys(true)).toContain('accounting')
})
it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => {
expect(buildClientFormTabKeys(false)).not.toContain('accounting')
})
it('place accounting entre transport et statistics quand present', () => {
const keys = buildClientFormTabKeys(true)
expect(keys).toEqual([
'information',
'contact',
'address',
'transport',
'accounting',
'statistics',
'reports',
'exchanges',
])
})
})
describe('isContactNamed (RG-1.05)', () => {
it('vrai si le prenom est renseigne', () => {
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
})
it('vrai si le nom est renseigne', () => {
expect(isContactNamed({ firstName: null, lastName: 'Martin' })).toBe(true)
})
it('faux si les deux sont vides ou espaces uniquement', () => {
expect(isContactNamed({ firstName: null, lastName: null })).toBe(false)
expect(isContactNamed({ firstName: ' ', lastName: '' })).toBe(false)
})
})
describe('hasAtLeastOneValidContact (RG-1.14)', () => {
it('faux sur une liste vide', () => {
expect(hasAtLeastOneValidContact([])).toBe(false)
})
it('faux si aucun contact n a de nom ni prenom', () => {
const contacts: ContactDraft[] = [
{ firstName: null, lastName: null },
{ firstName: '', lastName: ' ' },
]
expect(hasAtLeastOneValidContact(contacts)).toBe(false)
})
it('vrai des qu un contact a un nom ou un prenom', () => {
const contacts: ContactDraft[] = [
{ firstName: null, lastName: null },
{ firstName: 'Bob', lastName: null },
]
expect(hasAtLeastOneValidContact(contacts)).toBe(true)
})
})
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false)
})
it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false)
})
it('cocher Prospect efface Livraison et Facturation', () => {
const next = applyProspectExclusivity(
{ isProspect: false, isDelivery: true, isBilling: true },
'isProspect',
true,
)
expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
})
it('cocher Livraison efface Prospect', () => {
const next = applyProspectExclusivity(
{ isProspect: true, isDelivery: false, isBilling: false },
'isDelivery',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
})
it('cocher Facturation efface Prospect mais conserve Livraison', () => {
const next = applyProspectExclusivity(
{ isProspect: true, isDelivery: true, isBilling: false },
'isBilling',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
})
it('decocher un drapeau ne reactive rien d autre', () => {
const next = applyProspectExclusivity(
{ isProspect: false, isDelivery: true, isBilling: true },
'isBilling',
false,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
})
})
describe('isBillingEmailRequired (RG-1.11)', () => {
it('obligatoire uniquement si Facturation est coche', () => {
expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true)
expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
})
})
describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
it('banque obligatoire si VIREMENT', () => {
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
expect(isBankRequiredForPaymentType(null)).toBe(false)
})
it('RIB obligatoire si LCR', () => {
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
expect(isRibRequiredForPaymentType(null)).toBe(false)
})
})
@@ -0,0 +1,143 @@
/**
* Regles metier pures de l'ecran « Ajouter un client » (M1 Commercial).
*
* Centralisees ici (hors composant) pour rester testables unitairement et
* partagees entre la page de creation et les futurs ecrans d'edition (1.11/1.12).
* Ces helpers ne touchent ni a l'API ni a l'etat reactif : ils prennent des
* brouillons « plats » et retournent des booleens / nouveaux objets.
*
* Le back reste la source de verite (les RG sont re-validees serveur) ; ces
* regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite).
*
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement
* NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code
* de role (roles = IRIs opaques) et Bureau partage les memes permissions que
* Commerciale : aucun signal fiable pour distinguer le role cote front. Le back
* (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ;
* a rebrancher ici des qu'un code de role sera expose dans /api/me.
*/
/**
* Onglets « coquille » (non encore implementes) : frame vide, passage
* automatique a l'onglet suivant (decision Tristan 28/05).
*/
export const CLIENT_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const
/**
* Construit l'ordre des onglets de l'ecran « Ajouter un client ». L'onglet
* Comptabilite n'est present que si l'utilisateur a `accounting.view` — sinon il
* est totalement absent (Bureau / Commerciale ne le voient pas). Ordre aligne
* sur la spec M1 § Ecran « Ajouter un client ».
*/
export function buildClientFormTabKeys(canAccountingView: boolean): string[] {
const keys = ['information', 'contact', 'address', 'transport']
if (canAccountingView) {
keys.push('accounting')
}
keys.push('statistics', 'reports', 'exchanges')
return keys
}
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
export interface ContactDraft {
firstName: string | null
lastName: string | null
}
/** Drapeaux d'usage d'une adresse (RG-1.06/07/08/11). */
export interface AddressFlagsDraft {
isProspect: boolean
isDelivery: boolean
isBilling: boolean
}
/** 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-1.05 : un contact est valide des qu'il porte un nom OU un prenom.
*/
export function isContactNamed(contact: ContactDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/**
* RG-1.14 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
* contact nomme (nom ou prenom).
*/
export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
return contacts.some(isContactNamed)
}
/**
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
* Facturation ne sont coches.
*/
export function canSelectProspect(flags: AddressFlagsDraft): boolean {
return !flags.isDelivery && !flags.isBilling
}
/**
* RG-1.06/07/08 : Livraison et Facturation ne sont selectionnables que si
* Prospect n'est pas coche.
*/
export function canSelectDeliveryOrBilling(flags: AddressFlagsDraft): boolean {
return !flags.isProspect
}
/**
* Applique l'exclusivite Prospect / (Livraison|Facturation) au changement d'un
* drapeau. Cocher Prospect efface Livraison + Facturation ; cocher Livraison ou
* Facturation efface Prospect. Decocher n'a aucun effet de bord. Retourne un
* nouvel objet (pas de mutation de l'entree).
*/
export function applyProspectExclusivity(
flags: AddressFlagsDraft,
field: keyof AddressFlagsDraft,
value: boolean,
): AddressFlagsDraft {
const next: AddressFlagsDraft = { ...flags, [field]: value }
if (value && field === 'isProspect') {
next.isDelivery = false
next.isBilling = false
}
else if (value && (field === 'isDelivery' || field === 'isBilling')) {
next.isProspect = false
}
return next
}
/**
* RG-1.11 : l'email de facturation n'est visible/obligatoire que si l'adresse
* est une adresse de facturation.
*/
export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
return flags.isBilling
}
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
/** Code stable du type de reglement « lettre de change » (RG-1.13). */
const PAYMENT_TYPE_LCR = 'LCR'
/**
* RG-1.12 : la banque est obligatoire lorsque le type de reglement est un
* virement.
*/
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_TRANSFER
}
/**
* RG-1.13 : au moins un RIB complet est obligatoire lorsque le type de reglement
* est une LCR.
*/
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_LCR
}
@@ -0,0 +1,60 @@
// STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66.
//
// Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre
// qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels
// api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS
// changer leur signature ni l'usage côté composant.
//
// Contrat figé par ERP-66 (c'est lui qui fait foi) :
// searchCity(postalCode) -> liste { city, postalCode }
// searchAddress(query, cp?) -> liste { label, street, postalCode, city }
// En cas d'erreur/timeout, la méthode THROW. Le composant catch l'erreur,
// affiche un toast d'avertissement et bascule en saisie libre (MalioInputText).
//
// Comportement du stub : les deux méthodes throw systématiquement → l'onglet
// Adresse part directement en mode dégradé (Ville + Adresse en saisie libre,
// Code postal saisi manuellement). Aucun appel réseau n'est émis ici.
/** Une suggestion de ville renvoyée à partir d'un code postal. */
export interface CitySuggestion {
city: string
postalCode: string
}
/** Une suggestion d'adresse complète (saisie assistée du champ « Adresse »). */
export interface AddressSuggestion {
label: string
street: string
postalCode: string
city: string
}
export interface AddressAutocomplete {
searchCity(postalCode: string): Promise<CitySuggestion[]>
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
}
/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */
export class AddressAutocompleteUnavailableError extends Error {
constructor() {
// Message technique (non affiché tel quel) : le composant remonte son
// propre libellé i18n. Sert au debug / aux logs uniquement.
super('Address autocomplete (BAN) is not available yet — ERP-66 stub.')
this.name = 'AddressAutocompleteUnavailableError'
}
}
/**
* STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes
* échouent toujours, forçant le mode dégradé côté onglet Adresse.
*/
export function useAddressAutocomplete(): AddressAutocomplete {
return {
async searchCity(_postalCode: string): Promise<CitySuggestion[]> {
throw new AddressAutocompleteUnavailableError()
},
async searchAddress(_query: string, _postalCode?: string): Promise<AddressSuggestion[]> {
throw new AddressAutocompleteUnavailableError()
},
}
}