refactor(front) : aligne l'ecran ajouter client sur la maquette (ERP-63)

- Layout maquette : en-tete avec retour, grille 3 colonnes (gap-x-[80px]),
  cartes ombrees pour les onglets, boutons Valider centres, libelles ajustes.
- Telephones du formulaire principal en tableau (1 par defaut, + revele le 2e).
- Information : Description en row-span-2 (alignement corrige via pt-1),
  Nombre de salaries en MalioInputText masque chiffres.
- Adresse : carte ombree, suppression en absolute, sites en cases a cocher
  inline, pays France/Espagne, exclusivite Prospect appliquee au toggle.
- Onglets : icones par onglet (TAB_ICONS) ; Statistiques / Rapports / Echanges
  passent en edit-only (absents a la creation, option includeEditOnlyTabs pour
  la modification).
This commit is contained in:
2026-06-02 18:01:18 +02:00
parent 29ee4e9fd0
commit 955f9a436f
5 changed files with 398 additions and 331 deletions
+9 -8
View File
@@ -102,19 +102,20 @@
}, },
"form": { "form": {
"title": "Ajouter un client", "title": "Ajouter un client",
"back": "Précédent",
"submit": "Valider", "submit": "Valider",
"duplicateCompany": "Un client portant ce nom de société existe déjà.", "duplicateCompany": "Un client portant ce nom de société existe déjà.",
"main": { "main": {
"companyName": "Nom de l'entreprise", "companyName": "Nom du client (Entreprise)",
"firstName": "Prénom", "firstName": "Prénom du contact principal",
"lastName": "Nom", "lastName": "Nom du contact principal",
"email": "Email", "email": "Email",
"phonePrimary": "Téléphone", "phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)", "phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro", "addPhone": "Ajouter un numéro",
"categories": "Catégories", "categories": "Catégorie",
"relation": "Relation", "relation": "Distributeur / Courtier",
"relationNone": "Aucune", "relationNone": "Aucun",
"relationDistributor": "Distributeur", "relationDistributor": "Distributeur",
"relationBroker": "Courtier", "relationBroker": "Courtier",
"distributorName": "Nom du distributeur", "distributorName": "Nom du distributeur",
@@ -123,10 +124,10 @@
}, },
"information": { "information": {
"description": "Description", "description": "Description",
"competitors": "Concurrents", "competitors": "Concurrent",
"foundedAt": "Date de création", "foundedAt": "Date de création",
"employeesCount": "Nombre de salariés", "employeesCount": "Nombre de salariés",
"revenueAmount": "Chiffre d'affaires", "revenueAmount": "CA",
"profitAmount": "Résultat", "profitAmount": "Résultat",
"directorName": "Dirigeant" "directorName": "Dirigeant"
}, },
@@ -1,153 +1,150 @@
<template> <template>
<div class="rounded-md border border-neutral-200 bg-white p-6"> <div class="relative grid grid-cols-3 gap-x-[80px] gap-y-5 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="mb-4 flex items-center justify-between"> <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<h3 class="text-lg font-bold">{{ title }}</h3> <MalioButtonIcon
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> v-if="removable && !readonly"
<MalioButtonIcon icon="mdi:delete-outline"
v-if="removable && !readonly" variant="ghost"
icon="mdi:trash-can-outline" button-class="absolute top-3 right-3"
variant="ghost" v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }" @click="$emit('remove')"
@click="$emit('remove')" />
/>
</div>
<!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation <!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation
(RG-1.06/07/08). On masque l'option incompatible selon l'etat. --> (RG-1.06/07/08). L'exclusivite est appliquee au toggle (cocher l'un
<div class="mb-4 flex flex-wrap gap-6"> decoche l'autre) plutot qu'en masquant les options. -->
<MalioCheckbox
:model-value="model.isProspect"
:label="t('commercial.clients.form.address.prospect')"
group-class="self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
/>
<MalioCheckbox
:model-value="model.isDelivery"
:label="t('commercial.clients.form.address.delivery')"
group-class="self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
/>
<MalioCheckbox
:model-value="model.isBilling"
:label="t('commercial.clients.form.address.billing')"
group-class="self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
/>
<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)"
/>
<!-- Sites Starseed : cases a cocher inline (>= 1 obligatoire, RG-1.10). -->
<div class="flex justify-between">
<MalioCheckbox <MalioCheckbox
v-if="canSelectProspect(model)" v-for="site in siteOptions"
:model-value="model.isProspect" :key="site.value"
:label="t('commercial.clients.form.address.prospect')" :model-value="model.siteIris.includes(site.value)"
:label="site.label"
group-class="w-auto self-center"
:readonly="readonly" :readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)" @update:model-value="(v: boolean) => toggleSite(site.value, 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>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <MalioSelectCheckbox
<MalioSelectCheckbox :model-value="model.contactIris"
:model-value="model.categoryIris" :options="contactOptions"
:options="categoryOptions" :label="t('commercial.clients.form.address.contacts')"
:label="t('commercial.clients.form.address.categories')" :display-tag="true"
:display-tag="true" :disabled="readonly"
:disabled="readonly" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" />
/>
<MalioSelect <!-- Email de facturation : visible/obligatoire seulement si Facturation
:model-value="model.country" est coche (RG-1.11). -->
:options="countryOptions" <MalioInputText
:label="t('commercial.clients.form.address.country')" v-if="isBillingEmailRequired(model)"
:disabled="readonly" :model-value="model.billingEmail"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" :label="t('commercial.clients.form.address.billingEmail')"
/> :required="true"
:readonly="readonly"
<MalioInputText @update:model-value="(v: string) => update('billingEmail', v)"
: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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
applyProspectExclusivity, applyProspectExclusivity,
canSelectDeliveryOrBilling,
canSelectProspect,
isBillingEmailRequired, isBillingEmailRequired,
type AddressFlagsDraft, type AddressFlagsDraft,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
@@ -199,6 +196,15 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/** Coche/decoche un site Starseed rattache a l'adresse (M2M par IRI, RG-1.10). */
function toggleSite(siteIri: string, selected: boolean): void {
const current = props.modelValue.siteIris
const next = selected
? [...current, siteIri]
: current.filter(iri => iri !== siteIri)
update('siteIris', next)
}
/** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */ /** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */
function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void { function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void {
const flags = applyProspectExclusivity( const flags = applyProspectExclusivity(
+217 -180
View File
@@ -1,160 +1,166 @@
<template> <template>
<div> <div>
<PageHeader>{{ t('commercial.clients.form.title') }}</PageHeader> <!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
@click="goBack"
/>
<h1 class="text-[32px] font-bold text-m-primary">{{ t('commercial.clients.form.title') }}</h1>
</div>
<!-- Formulaire principal (pre-onglets) <!-- Formulaire principal (pre-onglets)
Sans validation de ce bloc, les onglets restent inaccessibles. Au Sans validation de ce bloc, les onglets restent inaccessibles. Au
succes du POST, les champs passent en lecture seule et on bascule succes du POST, les champs passent en lecture seule et on bascule
automatiquement sur l'onglet Information. --> automatiquement sur l'onglet Information. -->
<section class="mb-8 rounded-md border border-neutral-200 bg-white p-6"> <div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <MalioInputText
<MalioInputText v-model="main.companyName"
v-model="main.companyName" :label="t('commercial.clients.form.main.companyName')"
:label="t('commercial.clients.form.main.companyName')" :required="true"
:required="true" :readonly="mainLocked"
:readonly="mainLocked" />
/> <MalioInputText
<MalioInputEmail v-model="main.lastName"
v-model="main.email" :label="t('commercial.clients.form.main.lastName')"
:label="t('commercial.clients.form.main.email')" :readonly="mainLocked"
:required="true" />
:readonly="mainLocked" <MalioInputText
/> v-model="main.firstName"
<MalioInputText :label="t('commercial.clients.form.main.firstName')"
v-model="main.lastName" :readonly="mainLocked"
:label="t('commercial.clients.form.main.lastName')" />
:readonly="mainLocked" <MalioSelectCheckbox
/> :model-value="main.categoryIris"
<MalioInputText :options="referentials.categories.value"
v-model="main.firstName" :label="t('commercial.clients.form.main.categories')"
:label="t('commercial.clients.form.main.firstName')" :display-tag="true"
:readonly="mainLocked" :disabled="mainLocked"
/> @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
<MalioInputPhone />
v-model="main.phonePrimary" <!-- Telephones : 1 par defaut, le bouton « + » revele le 2e (max 2, RG-1.02). -->
:label="t('commercial.clients.form.main.phonePrimary')" <MalioInputPhone
:mask="PHONE_MASK" v-for="(_, index) in mainPhones"
:required="true" :key="index"
:readonly="mainLocked" v-model="mainPhones[index]"
:addable="!main.hasSecondaryPhone && !mainLocked" :label="t('commercial.clients.form.main.phonePrimary')"
:add-button-label="t('commercial.clients.form.main.addPhone')" :mask="PHONE_MASK"
@add="main.hasSecondaryPhone = true" :required="index === 0"
/> :readonly="mainLocked"
<MalioInputPhone add-icon-name="mdi:plus"
v-if="main.hasSecondaryPhone" :addable="mainPhones.length === 1 && !mainLocked"
v-model="main.phoneSecondary" :add-button-label="t('commercial.clients.form.main.addPhone')"
:label="t('commercial.clients.form.main.phoneSecondary')" @add="addMainPhone"
:mask="PHONE_MASK" />
:readonly="mainLocked" <MalioInputEmail
/> v-model="main.email"
<MalioSelectCheckbox :label="t('commercial.clients.form.main.email')"
:model-value="main.categoryIris" :required="true"
:options="referentials.categories.value" :readonly="mainLocked"
:label="t('commercial.clients.form.main.categories')" />
:display-tag="true" <MalioSelect
:disabled="mainLocked" :model-value="main.relationType"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" :options="relationOptions"
/> :label="t('commercial.clients.form.main.relation')"
<MalioSelect :disabled="mainLocked"
:model-value="main.relationType" @update:model-value="onRelationChange"
:options="relationOptions" />
:label="t('commercial.clients.form.main.relation')" <MalioSelect
:disabled="mainLocked" v-if="main.relationType === 'courtier'"
@update:model-value="onRelationChange" :model-value="main.brokerIri"
/> :options="referentials.brokers.value"
<MalioSelect :label="t('commercial.clients.form.main.brokerName')"
v-if="main.relationType === 'distributeur'" :disabled="mainLocked"
:model-value="main.distributorIri" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
:options="referentials.distributors.value" />
:label="t('commercial.clients.form.main.distributorName')" <MalioSelect
:disabled="mainLocked" v-if="main.relationType === 'distributeur'"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" :model-value="main.distributorIri"
/> :options="referentials.distributors.value"
<MalioSelect :label="t('commercial.clients.form.main.distributorName')"
v-if="main.relationType === 'courtier'" :disabled="mainLocked"
:model-value="main.brokerIri" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
:options="referentials.brokers.value" />
:label="t('commercial.clients.form.main.brokerName')" <MalioCheckbox
:disabled="mainLocked" v-model="main.triageService"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" :label="t('commercial.clients.form.main.triageService')"
/> group-class="self-center"
</div> :readonly="mainLocked"
/>
</div>
<div class="mt-4 flex items-center gap-6"> <div v-if="!mainLocked" class="mt-12 flex justify-center">
<MalioCheckbox <MalioButton
v-model="main.triageService" variant="primary"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.submit')"
:readonly="mainLocked" :disabled="!isMainValid || mainSubmitting"
/> @click="submitMain"
<MalioButton />
v-if="!mainLocked" </div>
class="ml-auto"
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!isMainValid || mainSubmitting"
@click="submitMain"
/>
</div>
</section>
<!-- ── Onglets a validation incrementale ─────────────────────────────--> <!-- ── Onglets a validation incrementale ─────────────────────────────-->
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="rounded-md border border-neutral-200 bg-white p-6"> <div class="mt-12 grid grid-cols-3 gap-x-[80px] gap-y-5 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <!-- pt-1 : aligne le bord superieur du textarea sur celui des
<MalioInputTextArea inputs (centres dans un conteneur h-12, soit ~4px de retrait haut). -->
v-model="information.description" <MalioInputTextArea
:label="t('commercial.clients.form.information.description')" v-model="information.description"
:disabled="isValidated('information')" :label="t('commercial.clients.form.information.description')"
group-class="md:col-span-2" resize="none"
/> group-class="row-span-2 pt-1"
<MalioInputText text-input="h-full text-lg"
v-model="information.competitors" :disabled="isValidated('information')"
:label="t('commercial.clients.form.information.competitors')" />
:readonly="isValidated('information')" <MalioInputText
/> v-model="information.competitors"
<MalioDate :label="t('commercial.clients.form.information.competitors')"
v-model="information.foundedAt" :readonly="isValidated('information')"
:label="t('commercial.clients.form.information.foundedAt')" />
:readonly="isValidated('information')" <MalioDate
/> v-model="information.foundedAt"
<MalioInputNumber :label="t('commercial.clients.form.information.foundedAt')"
v-model="information.employeesCount" :readonly="isValidated('information')"
:label="t('commercial.clients.form.information.employeesCount')" />
min="0" <MalioInputText
:disabled="isValidated('information')" v-model="information.employeesCount"
/> :label="t('commercial.clients.form.information.employeesCount')"
<MalioInputText :mask="EMPLOYEES_MASK"
v-model="information.directorName" :readonly="isValidated('information')"
:label="t('commercial.clients.form.information.directorName')" />
:readonly="isValidated('information')" <MalioInputAmount
/> v-model="information.revenueAmount"
<MalioInputAmount :label="t('commercial.clients.form.information.revenueAmount')"
v-model="information.revenueAmount" :disabled="isValidated('information')"
:label="t('commercial.clients.form.information.revenueAmount')" />
:disabled="isValidated('information')" <MalioInputText
/> v-model="information.directorName"
<MalioInputAmount :label="t('commercial.clients.form.information.directorName')"
v-model="information.profitAmount" :readonly="isValidated('information')"
:label="t('commercial.clients.form.information.profitAmount')" />
:disabled="isValidated('information')" <MalioInputAmount
/> v-model="information.profitAmount"
</div> :label="t('commercial.clients.form.information.profitAmount')"
<div v-if="!isValidated('information')" class="mt-4 flex justify-end"> :disabled="isValidated('information')"
<MalioButton />
variant="primary" </div>
:label="t('commercial.clients.form.submit')" <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
:disabled="tabSubmitting" <MalioButton
@click="submitInformation" variant="primary"
/> :label="t('commercial.clients.form.submit')"
</div> :disabled="tabSubmitting"
@click="submitInformation"
/>
</div> </div>
</template> </template>
<!-- Onglet Contact --> <!-- Onglet Contact -->
<template #contact> <template #contact>
<div class="flex flex-col gap-4"> <div class="mt-12 flex flex-col gap-6">
<ClientContactBlock <ClientContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
@@ -165,10 +171,10 @@
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
<div v-if="!isValidated('contact')" class="flex items-center justify-between"> <div v-if="!isValidated('contact')" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.contact.add')" :label="t('commercial.clients.form.contact.add')"
:disabled="!canAddContact" :disabled="!canAddContact"
@@ -186,7 +192,7 @@
<!-- Onglet Adresse --> <!-- Onglet Adresse -->
<template #address> <template #address>
<div class="flex flex-col gap-4"> <div class="mt-12 flex flex-col gap-6">
<ClientAddressBlock <ClientAddressBlock
v-for="(address, index) in addresses" v-for="(address, index) in addresses"
:key="index" :key="index"
@@ -202,10 +208,10 @@
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@degraded="onAddressDegraded" @degraded="onAddressDegraded"
/> />
<div v-if="!isValidated('address')" class="flex items-center justify-between"> <div v-if="!isValidated('address')" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.address.add')" :label="t('commercial.clients.form.address.add')"
@click="addAddress" @click="addAddress"
@@ -222,9 +228,9 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) --> <!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="flex flex-col gap-4"> <div class="mt-12 flex flex-col gap-6">
<div class="rounded-md border border-neutral-200 bg-white p-6"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -281,20 +287,18 @@
<div <div
v-for="(rib, index) in ribs" v-for="(rib, index) in ribs"
:key="index" :key="index"
class="rounded-md border border-neutral-200 bg-white p-6" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<div class="mb-4 flex items-center justify-between"> <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<h3 class="text-lg font-bold">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h3> <MalioButtonIcon
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> v-if="!accountingReadonly"
<MalioButtonIcon icon="mdi:delete-outline"
v-if="!accountingReadonly" variant="ghost"
icon="mdi:trash-can-outline" button-class="absolute top-3 right-3"
variant="ghost" v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" @click="askRemoveRib(index)"
@click="askRemoveRib(index)" />
/> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -313,10 +317,10 @@
</div> </div>
</div> </div>
<div v-if="!accountingReadonly" class="flex items-center justify-between"> <div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')" :label="t('commercial.clients.form.accounting.addRib')"
@click="addRib" @click="addRib"
@@ -331,11 +335,10 @@
</div> </div>
</template> </template>
<!-- Onglets non encore implementes : frame vide, passage automatique. --> <!-- Onglet non encore implemente : frame vide, passage automatique.
Statistiques / Rapports / Echanges sont edit-only (absents a la
creation) — cf. buildClientFormTabKeys. -->
<template #transport><TabPlaceholderBlank /></template> <template #transport><TabPlaceholderBlank /></template>
<template #statistics><TabPlaceholderBlank /></template>
<template #reports><TabPlaceholderBlank /></template>
<template #exchanges><TabPlaceholderBlank /></template>
</MalioTabList> </MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). --> <!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
@@ -387,6 +390,8 @@ import { formatPhoneFR } from '~/shared/utils/phone'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
const EMPLOYEES_MASK = '#######'
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78). // Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'] const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
@@ -394,8 +399,14 @@ const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
const toast = useToast() const toast = useToast()
const router = useRouter()
const { can } = usePermissions() const { can } = usePermissions()
/** Retour vers le repertoire clients (fleche d'en-tete). */
function goBack(): void {
router.push('/clients')
}
useHead({ title: t('commercial.clients.form.title') }) useHead({ title: t('commercial.clients.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting // Gating de la route : la creation est reservee a `manage`. Compta (accounting
@@ -421,9 +432,6 @@ const main = reactive({
firstName: null as string | null, firstName: null as string | null,
lastName: null as string | null, lastName: null as string | null,
email: null as string | null, email: null as string | null,
phonePrimary: null as string | null,
phoneSecondary: null as string | null,
hasSecondaryPhone: false,
categoryIris: [] as string[], categoryIris: [] as string[],
relationType: 'aucun' as 'aucun' | 'distributeur' | 'courtier', relationType: 'aucun' as 'aucun' | 'distributeur' | 'courtier',
distributorIri: null as string | null, distributorIri: null as string | null,
@@ -431,18 +439,29 @@ const main = reactive({
triageService: false, triageService: false,
}) })
// Telephones du formulaire principal : 1 par defaut, 2 au maximum (RG-1.02).
// L'index 0 alimente phonePrimary, l'index 1 phoneSecondary au POST.
const mainPhones = ref<string[]>([''])
/** Revele le 2e numero (le bouton « + » disparait une fois a 2, RG-1.02). */
function addMainPhone(): void {
if (mainPhones.value.length === 1) {
mainPhones.value.push('')
}
}
const relationOptions = computed<RefOption[]>(() => [ const relationOptions = computed<RefOption[]>(() => [
{ value: 'aucun', label: t('commercial.clients.form.main.relationNone') }, { value: 'aucun', label: t('commercial.clients.form.main.relationNone') },
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
]) ])
// RG-1.01 : firstName OU lastName, + companyName / email / phonePrimary requis. // RG-1.01 : firstName OU lastName, + companyName / email / telephone principal requis.
const isMainValid = computed(() => { const isMainValid = computed(() => {
const filled = (v: string | null) => v !== null && v.trim() !== '' const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
return filled(main.companyName) return filled(main.companyName)
&& filled(main.email) && filled(main.email)
&& filled(main.phonePrimary) && filled(mainPhones.value[0])
&& (filled(main.firstName) || filled(main.lastName)) && (filled(main.firstName) || filled(main.lastName))
}) })
@@ -467,8 +486,8 @@ async function submitMain(): Promise<void> {
firstName: main.firstName || null, firstName: main.firstName || null,
lastName: main.lastName || null, lastName: main.lastName || null,
email: main.email, email: main.email,
phonePrimary: main.phonePrimary, phonePrimary: mainPhones.value[0] || null,
phoneSecondary: main.hasSecondaryPhone ? (main.phoneSecondary || null) : null, phoneSecondary: mainPhones.value[1] || null,
categories: main.categoryIris, categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null, distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null,
@@ -485,8 +504,10 @@ async function submitMain(): Promise<void> {
main.firstName = created.firstName ?? null main.firstName = created.firstName ?? null
main.lastName = created.lastName ?? null main.lastName = created.lastName ?? null
main.email = created.email ?? main.email main.email = created.email ?? main.email
main.phonePrimary = formatPhoneFR(created.phonePrimary) || main.phonePrimary // Reaffiche les telephones normalises (reformates via formatPhoneFR).
main.phoneSecondary = formatPhoneFR(created.phoneSecondary) || null const normalizedPhones = [formatPhoneFR(created.phonePrimary), formatPhoneFR(created.phoneSecondary)]
.filter(p => p !== '')
mainPhones.value = normalizedPhones.length > 0 ? normalizedPhones : ['']
// Pre-remplit le 1er contact a partir du formulaire principal (editable). // Pre-remplit le 1er contact a partir du formulaire principal (editable).
prefillFirstContact() prefillFirstContact()
@@ -518,9 +539,22 @@ const validated = reactive<Record<string, boolean>>({})
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value)) const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
// Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
transport: 'mdi:truck-delivery-outline',
accounting: 'mdi:bank-circle-outline',
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map((key, index) => ({ const tabs = computed(() => tabKeys.value.map((key, index) => ({
key, key,
label: t(`commercial.clients.tab.${key}`), label: t(`commercial.clients.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value, disabled: index > unlockedIndex.value,
}))) })))
@@ -595,7 +629,7 @@ function prefillFirstContact(): void {
first.lastName = main.lastName first.lastName = main.lastName
first.firstName = main.firstName first.firstName = main.firstName
first.email = main.email first.email = main.email
first.phonePrimary = main.phonePrimary first.phonePrimary = mainPhones.value[0] ?? null
} }
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom. // « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
@@ -678,8 +712,11 @@ const contactOptions = computed<RefOption[]>(() =>
})), })),
) )
// France par defaut au M1 (liste pays minimale ; a etendre quand le besoin viendra). // Pays disponibles (France preselectionnee par defaut sur chaque adresse).
const countryOptions: RefOption[] = [{ value: 'France', label: 'France' }] const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// RG-1.10 (>= 1 site) + RG-1.11 (email facturation si Facturation) sur chaque adresse. // RG-1.10 (>= 1 site) + RG-1.11 (email facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() => const canValidateAddresses = computed(() =>
@@ -12,7 +12,7 @@ import {
type ContactDraft, type ContactDraft,
} from '../clientFormRules' } from '../clientFormRules'
describe('buildClientFormTabKeys (gating onglet Comptabilite)', () => { describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
it('inclut l onglet accounting si l utilisateur a accounting.view', () => { it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
expect(buildClientFormTabKeys(true)).toContain('accounting') expect(buildClientFormTabKeys(true)).toContain('accounting')
}) })
@@ -21,8 +21,16 @@ describe('buildClientFormTabKeys (gating onglet Comptabilite)', () => {
expect(buildClientFormTabKeys(false)).not.toContain('accounting') expect(buildClientFormTabKeys(false)).not.toContain('accounting')
}) })
it('place accounting entre transport et statistics quand present', () => { it('a la creation, exclut Statistiques / Rapports / Echanges', () => {
const keys = buildClientFormTabKeys(true) const keys = buildClientFormTabKeys(true)
expect(keys).toEqual(['information', 'contact', 'address', 'transport', 'accounting'])
expect(keys).not.toContain('statistics')
expect(keys).not.toContain('reports')
expect(keys).not.toContain('exchanges')
})
it('en modification (includeEditOnlyTabs), ajoute les onglets edit-only en fin', () => {
const keys = buildClientFormTabKeys(true, { includeEditOnlyTabs: true })
expect(keys).toEqual([ expect(keys).toEqual([
'information', 'information',
'contact', 'contact',
@@ -24,17 +24,32 @@
export const CLIENT_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const 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 * Onglets affiches uniquement en MODIFICATION (selon le role), jamais a la
* Comptabilite n'est present que si l'utilisateur a `accounting.view` — sinon il * creation : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans
* est totalement absent (Bureau / Commerciale ne le voient pas). Ordre aligne * d'edition (1.11/1.12) via l'option `includeEditOnlyTabs`.
* sur la spec M1 § Ecran « Ajouter un client ».
*/ */
export function buildClientFormTabKeys(canAccountingView: boolean): string[] { export const CLIENT_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const
/**
* Construit l'ordre des onglets du formulaire client.
* - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view`
* (Bureau / Commerciale ne le voient pas).
* - Les onglets edit-only (Statistiques / Rapports / Echanges) sont exclus par
* defaut (creation) ; passer `includeEditOnlyTabs: true` pour les afficher en
* modification.
* Ordre aligne sur la spec M1 § Ecran « Ajouter un client ».
*/
export function buildClientFormTabKeys(
canAccountingView: boolean,
options: { includeEditOnlyTabs?: boolean } = {},
): string[] {
const keys = ['information', 'contact', 'address', 'transport'] const keys = ['information', 'contact', 'address', 'transport']
if (canAccountingView) { if (canAccountingView) {
keys.push('accounting') keys.push('accounting')
} }
keys.push('statistics', 'reports', 'exchanges') if (options.includeEditOnlyTabs) {
keys.push(...CLIENT_FORM_EDIT_ONLY_TABS)
}
return keys return keys
} }