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:
@@ -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,44 +1,40 @@
|
|||||||
<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">
|
|
||||||
<h3 class="text-lg font-bold">{{ title }}</h3>
|
|
||||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="removable && !readonly"
|
v-if="removable && !readonly"
|
||||||
icon="mdi:trash-can-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
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
|
<MalioCheckbox
|
||||||
v-if="canSelectProspect(model)"
|
|
||||||
:model-value="model.isProspect"
|
:model-value="model.isProspect"
|
||||||
:label="t('commercial.clients.form.address.prospect')"
|
:label="t('commercial.clients.form.address.prospect')"
|
||||||
|
group-class="self-center"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
|
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
|
||||||
/>
|
/>
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
v-if="canSelectDeliveryOrBilling(model)"
|
|
||||||
:model-value="model.isDelivery"
|
:model-value="model.isDelivery"
|
||||||
:label="t('commercial.clients.form.address.delivery')"
|
:label="t('commercial.clients.form.address.delivery')"
|
||||||
|
group-class="self-center"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
|
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
|
||||||
/>
|
/>
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
v-if="canSelectDeliveryOrBilling(model)"
|
|
||||||
:model-value="model.isBilling"
|
:model-value="model.isBilling"
|
||||||
:label="t('commercial.clients.form.address.billing')"
|
:label="t('commercial.clients.form.address.billing')"
|
||||||
|
group-class="self-center"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
|
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
:model-value="model.categoryIris"
|
:model-value="model.categoryIris"
|
||||||
:options="categoryOptions"
|
:options="categoryOptions"
|
||||||
@@ -111,14 +107,18 @@
|
|||||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MalioSelectCheckbox
|
<!-- Sites Starseed : cases a cocher inline (>= 1 obligatoire, RG-1.10). -->
|
||||||
:model-value="model.siteIris"
|
<div class="flex justify-between">
|
||||||
:options="siteOptions"
|
<MalioCheckbox
|
||||||
:label="t('commercial.clients.form.address.sites')"
|
v-for="site in siteOptions"
|
||||||
:display-tag="true"
|
:key="site.value"
|
||||||
:disabled="readonly"
|
:model-value="model.siteIris.includes(site.value)"
|
||||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
:label="site.label"
|
||||||
|
group-class="w-auto self-center"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: boolean) => toggleSite(site.value, v)"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
:model-value="model.contactIris"
|
:model-value="model.contactIris"
|
||||||
@@ -140,14 +140,11 @@
|
|||||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
@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(
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
<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"
|
||||||
/>
|
/>
|
||||||
<MalioInputEmail
|
|
||||||
v-model="main.email"
|
|
||||||
:label="t('commercial.clients.form.main.email')"
|
|
||||||
:required="true"
|
|
||||||
:readonly="mainLocked"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="main.lastName"
|
v-model="main.lastName"
|
||||||
:label="t('commercial.clients.form.main.lastName')"
|
:label="t('commercial.clients.form.main.lastName')"
|
||||||
@@ -30,23 +33,6 @@
|
|||||||
:label="t('commercial.clients.form.main.firstName')"
|
:label="t('commercial.clients.form.main.firstName')"
|
||||||
:readonly="mainLocked"
|
: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
|
<MalioSelectCheckbox
|
||||||
:model-value="main.categoryIris"
|
:model-value="main.categoryIris"
|
||||||
:options="referentials.categories.value"
|
:options="referentials.categories.value"
|
||||||
@@ -55,6 +41,26 @@
|
|||||||
:disabled="mainLocked"
|
:disabled="mainLocked"
|
||||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
/>
|
/>
|
||||||
|
<!-- Telephones : 1 par defaut, le bouton « + » revele le 2e (max 2, RG-1.02). -->
|
||||||
|
<MalioInputPhone
|
||||||
|
v-for="(_, index) in mainPhones"
|
||||||
|
:key="index"
|
||||||
|
v-model="mainPhones[index]"
|
||||||
|
:label="t('commercial.clients.form.main.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:required="index === 0"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
add-icon-name="mdi:plus"
|
||||||
|
:addable="mainPhones.length === 1 && !mainLocked"
|
||||||
|
:add-button-label="t('commercial.clients.form.main.addPhone')"
|
||||||
|
@add="addMainPhone"
|
||||||
|
/>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="main.email"
|
||||||
|
:label="t('commercial.clients.form.main.email')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="main.relationType"
|
:model-value="main.relationType"
|
||||||
:options="relationOptions"
|
:options="relationOptions"
|
||||||
@@ -62,14 +68,6 @@
|
|||||||
:disabled="mainLocked"
|
:disabled="mainLocked"
|
||||||
@update:model-value="onRelationChange"
|
@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
|
<MalioSelect
|
||||||
v-if="main.relationType === 'courtier'"
|
v-if="main.relationType === 'courtier'"
|
||||||
:model-value="main.brokerIri"
|
:model-value="main.brokerIri"
|
||||||
@@ -78,36 +76,45 @@
|
|||||||
:disabled="mainLocked"
|
:disabled="mainLocked"
|
||||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
<MalioSelect
|
||||||
|
v-if="main.relationType === 'distributeur'"
|
||||||
<div class="mt-4 flex items-center gap-6">
|
: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)"
|
||||||
|
/>
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
v-model="main.triageService"
|
v-model="main.triageService"
|
||||||
:label="t('commercial.clients.form.main.triageService')"
|
:label="t('commercial.clients.form.main.triageService')"
|
||||||
|
group-class="self-center"
|
||||||
:readonly="mainLocked"
|
:readonly="mainLocked"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="!mainLocked"
|
|
||||||
class="ml-auto"
|
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.form.submit')"
|
:label="t('commercial.clients.form.submit')"
|
||||||
:disabled="!isMainValid || mainSubmitting"
|
:disabled="!isMainValid || mainSubmitting"
|
||||||
@click="submitMain"
|
@click="submitMain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
||||||
|
inputs (centres dans un conteneur h-12, soit ~4px de retrait haut). -->
|
||||||
<MalioInputTextArea
|
<MalioInputTextArea
|
||||||
v-model="information.description"
|
v-model="information.description"
|
||||||
:label="t('commercial.clients.form.information.description')"
|
:label="t('commercial.clients.form.information.description')"
|
||||||
|
resize="none"
|
||||||
|
group-class="row-span-2 pt-1"
|
||||||
|
text-input="h-full text-lg"
|
||||||
:disabled="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
group-class="md:col-span-2"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.competitors"
|
v-model="information.competitors"
|
||||||
@@ -119,10 +126,15 @@
|
|||||||
:label="t('commercial.clients.form.information.foundedAt')"
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
:readonly="isValidated('information')"
|
:readonly="isValidated('information')"
|
||||||
/>
|
/>
|
||||||
<MalioInputNumber
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
:label="t('commercial.clients.form.information.employeesCount')"
|
:label="t('commercial.clients.form.information.employeesCount')"
|
||||||
min="0"
|
:mask="EMPLOYEES_MASK"
|
||||||
|
:readonly="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="information.revenueAmount"
|
||||||
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
:disabled="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
@@ -130,18 +142,13 @@
|
|||||||
:label="t('commercial.clients.form.information.directorName')"
|
:label="t('commercial.clients.form.information.directorName')"
|
||||||
:readonly="isValidated('information')"
|
:readonly="isValidated('information')"
|
||||||
/>
|
/>
|
||||||
<MalioInputAmount
|
|
||||||
v-model="information.revenueAmount"
|
|
||||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
|
||||||
:disabled="isValidated('information')"
|
|
||||||
/>
|
|
||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
v-model="information.profitAmount"
|
v-model="information.profitAmount"
|
||||||
:label="t('commercial.clients.form.information.profitAmount')"
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
:disabled="isValidated('information')"
|
:disabled="isValidated('information')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isValidated('information')" class="mt-4 flex justify-end">
|
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.form.submit')"
|
:label="t('commercial.clients.form.submit')"
|
||||||
@@ -149,12 +156,11 @@
|
|||||||
@click="submitInformation"
|
@click="submitInformation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
|
||||||
<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). -->
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly"
|
v-if="!accountingReadonly"
|
||||||
icon="mdi:trash-can-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
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>
|
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
|
||||||
<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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user