Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1961bc62c8 | |||
| bc7c8f6f83 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.71'
|
app.version: '0.1.72'
|
||||||
|
|||||||
@@ -106,6 +106,15 @@
|
|||||||
"message": "Ce client réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
"message": "Ce client réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Modifier le client",
|
||||||
|
"back": "Retour au répertoire",
|
||||||
|
"loading": "Chargement du client…",
|
||||||
|
"notFound": "Client introuvable.",
|
||||||
|
"emptyContacts": "Aucun contact enregistré.",
|
||||||
|
"emptyAddresses": "Aucune adresse enregistrée.",
|
||||||
|
"save": "Valider"
|
||||||
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
||||||
"contactRequired": "Au moins un contact (nom ou prénom) est obligatoire.",
|
"contactRequired": "Au moins un contact (nom ou prénom) est obligatoire.",
|
||||||
|
|||||||
@@ -85,26 +85,32 @@ export function useClientReferentials() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge en parallele les referentiels communs (hors distributeurs/courtiers,
|
* Charge en parallele les referentiels communs (hors distributeurs/courtiers,
|
||||||
* charges a la demande selon la relation choisie). Les selects compta ne sont
|
* charges a la demande selon la relation choisie).
|
||||||
* pertinents que si l'utilisateur a acces a l'onglet, mais le cout est
|
*
|
||||||
* negligeable et simplifie l'orchestration.
|
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole.
|
||||||
|
* Necessaire pour les roles metier qui n'ont pas toutes les permissions de
|
||||||
|
* lecture — ex. Compta a `commercial.clients.view` (donc /tva_modes, /banks...
|
||||||
|
* accessibles) mais PAS `catalog.categories.view` ni `sites.view` : sans
|
||||||
|
* isolation, le 403 sur /categories ferait echouer tout le bloc et viderait
|
||||||
|
* les selects comptables dont Compta a besoin sur l'ecran de modification.
|
||||||
|
* Un referentiel en echec reste simplement vide (l'ecran d'edition complete
|
||||||
|
* l'affichage des valeurs courantes depuis l'embed du detail client).
|
||||||
*/
|
*/
|
||||||
async function loadCommon(): Promise<void> {
|
async function loadCommon(): Promise<void> {
|
||||||
const [cats, sitesList, tva, delays, types, banksList] = await Promise.all([
|
await Promise.allSettled([
|
||||||
fetchAll<CategoryMember>('/categories'),
|
fetchAll<CategoryMember>('/categories')
|
||||||
fetchAll<SiteMember>('/sites'),
|
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||||
fetchAll<ReferentialMember>('/tva_modes'),
|
fetchAll<SiteMember>('/sites')
|
||||||
fetchAll<ReferentialMember>('/payment_delays'),
|
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }),
|
||||||
fetchAll<ReferentialMember>('/payment_types'),
|
fetchAll<ReferentialMember>('/tva_modes')
|
||||||
fetchAll<ReferentialMember>('/banks'),
|
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||||
|
fetchAll<ReferentialMember>('/payment_delays')
|
||||||
|
.then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }),
|
||||||
|
fetchAll<ReferentialMember>('/payment_types')
|
||||||
|
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
|
||||||
|
fetchAll<ReferentialMember>('/banks')
|
||||||
|
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||||
])
|
])
|
||||||
|
|
||||||
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). */
|
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
|
||||||
|
|||||||
@@ -0,0 +1,909 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour repertoire + nom du client. -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etats de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.edit.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.edit.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="client">
|
||||||
|
<!-- ── Bloc principal (pre-rempli, editable si `manage`) ──────────────
|
||||||
|
Decision Tristan : on conserve le bloc principal en modification
|
||||||
|
(« pour ne pas tout casser »), edite via son propre PATCH scope
|
||||||
|
sur le groupe client:write:main. Readonly pour les roles sans
|
||||||
|
`manage` (ex. Compta). -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="main.companyName"
|
||||||
|
:label="t('commercial.clients.form.main.companyName')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="main.lastName"
|
||||||
|
:label="t('commercial.clients.form.main.lastName')"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="main.firstName"
|
||||||
|
:label="t('commercial.clients.form.main.firstName')"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="main.categoryIris"
|
||||||
|
:options="mainCategoryOptions"
|
||||||
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="main.phonePrimary"
|
||||||
|
:label="t('commercial.clients.form.main.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:required="true"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
add-icon-name="mdi:plus"
|
||||||
|
:addable="!main.hasSecondaryPhone && !businessReadonly"
|
||||||
|
: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="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="main.email"
|
||||||
|
:label="t('commercial.clients.form.main.email')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="main.relationType"
|
||||||
|
:options="relationOptions"
|
||||||
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
@update:model-value="onRelationChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="main.relationType === 'courtier'"
|
||||||
|
:model-value="main.brokerIri"
|
||||||
|
:options="brokerOptions"
|
||||||
|
:label="t('commercial.clients.form.main.brokerName')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="main.relationType === 'distributeur'"
|
||||||
|
:model-value="main.distributorIri"
|
||||||
|
:options="distributorOptions"
|
||||||
|
:label="t('commercial.clients.form.main.distributorName')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
v-model="main.triageService"
|
||||||
|
:label="t('commercial.clients.form.main.triageService')"
|
||||||
|
group-class="self-center"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="!isMainValid || mainSubmitting"
|
||||||
|
@click="submitMain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
|
||||||
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||||
|
<!-- Onglet Information -->
|
||||||
|
<template #information>
|
||||||
|
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
|
<MalioInputTextArea
|
||||||
|
v-model="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="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.competitors"
|
||||||
|
:label="t('commercial.clients.form.information.competitors')"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioDate
|
||||||
|
v-model="information.foundedAt"
|
||||||
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.employeesCount"
|
||||||
|
:label="t('commercial.clients.form.information.employeesCount')"
|
||||||
|
:mask="EMPLOYEES_MASK"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="information.revenueAmount"
|
||||||
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.directorName"
|
||||||
|
:label="t('commercial.clients.form.information.directorName')"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="information.profitAmount"
|
||||||
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="tabSubmitting"
|
||||||
|
@click="submitInformation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Contact -->
|
||||||
|
<template #contact>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ClientContactBlock
|
||||||
|
v-for="(contact, index) in contacts"
|
||||||
|
:key="contact.id ?? `new-${index}`"
|
||||||
|
:model-value="contact"
|
||||||
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
|
:removable="contacts.length > 1"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
|
@remove="askRemoveContact(index)"
|
||||||
|
/>
|
||||||
|
<p v-if="contacts.length === 0" class="text-center text-black/60">
|
||||||
|
{{ t('commercial.clients.edit.emptyContacts') }}
|
||||||
|
</p>
|
||||||
|
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.contact.add')"
|
||||||
|
:disabled="!canAddContact"
|
||||||
|
@click="addContact"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="!canValidateContacts || tabSubmitting"
|
||||||
|
@click="submitContacts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Adresse -->
|
||||||
|
<template #address>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ClientAddressBlock
|
||||||
|
v-for="(address, index) in addresses"
|
||||||
|
:key="address.id ?? `new-${index}`"
|
||||||
|
:model-value="address"
|
||||||
|
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||||
|
:category-options="addressCategoryOptions"
|
||||||
|
:site-options="siteOptions"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptions"
|
||||||
|
:removable="addresses.length > 1"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
|
@remove="askRemoveAddress(index)"
|
||||||
|
@degraded="onAddressDegraded"
|
||||||
|
/>
|
||||||
|
<p v-if="addresses.length === 0" class="text-center text-black/60">
|
||||||
|
{{ t('commercial.clients.edit.emptyAddresses') }}
|
||||||
|
</p>
|
||||||
|
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.address.add')"
|
||||||
|
@click="addAddress"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="!canValidateAddresses || tabSubmitting"
|
||||||
|
@click="submitAddresses"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Comptabilite (present uniquement si accounting.view ;
|
||||||
|
editable uniquement si accounting.manage). -->
|
||||||
|
<template v-if="canAccountingView" #accounting>
|
||||||
|
<div class="mt-12 flex flex-col gap-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-3 gap-x-[80px] gap-y-5">
|
||||||
|
<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="tvaModeOptions"
|
||||||
|
: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="paymentDelayOptions"
|
||||||
|
: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="paymentTypeOptions"
|
||||||
|
: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="bankOptions"
|
||||||
|
: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 (RG-1.13). -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in ribs"
|
||||||
|
:key="rib.id ?? `new-${index}`"
|
||||||
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
>
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="!accountingReadonly"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
|
||||||
|
<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 justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.accounting.addRib')"
|
||||||
|
@click="addRib"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="!canValidateAccounting || tabSubmitting"
|
||||||
|
@click="submitAccounting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
||||||
|
<template #transport><TabPlaceholderBlank /></template>
|
||||||
|
<template #statistics><TabPlaceholderBlank /></template>
|
||||||
|
<template #reports><TabPlaceholderBlank /></template>
|
||||||
|
<template #exchanges><TabPlaceholderBlank /></template>
|
||||||
|
</MalioTabList>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 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 } from 'vue'
|
||||||
|
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||||
|
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
|
import {
|
||||||
|
canEditClient,
|
||||||
|
categoryOptionsOf,
|
||||||
|
referentialOptionOf,
|
||||||
|
siteOptionsOf,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapAddressToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
type ClientDetail,
|
||||||
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
|
import {
|
||||||
|
buildAccountingPayload,
|
||||||
|
buildAddressPayload,
|
||||||
|
buildContactPayload,
|
||||||
|
buildInformationPayload,
|
||||||
|
buildMainPayload,
|
||||||
|
buildRibPayload,
|
||||||
|
mapAccountingFormDraft,
|
||||||
|
mapInformationDraft,
|
||||||
|
mapMainDraft,
|
||||||
|
resolveTabEditability,
|
||||||
|
type AccountingFormDraft,
|
||||||
|
type ClientEditAbilities,
|
||||||
|
type InformationFormDraft,
|
||||||
|
type MainFormDraft,
|
||||||
|
} from '~/modules/commercial/utils/clientEdit'
|
||||||
|
import {
|
||||||
|
buildClientFormTabKeys,
|
||||||
|
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 { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
|
const PHONE_MASK = '## ## ## ## ##'
|
||||||
|
const SIREN_MASK = '#########'
|
||||||
|
const EMPLOYEES_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 route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can, canAny } = usePermissions()
|
||||||
|
|
||||||
|
// Gating de la route : l'edition exige de pouvoir editer au moins un onglet
|
||||||
|
// (`manage` OU `accounting.manage`). Usine et roles en lecture seule sont
|
||||||
|
// rediriges vers le repertoire (lui-meme protege).
|
||||||
|
if (!canEditClient(canAny)) {
|
||||||
|
await navigateTo('/clients')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = route.params.id as string
|
||||||
|
|
||||||
|
const { client, loading, error, load } = useClient(clientId)
|
||||||
|
const referentials = useClientReferentials()
|
||||||
|
|
||||||
|
// ── Permissions / editabilite par zone (option 1 ERP-74) ────────────────────
|
||||||
|
const abilities = computed<ClientEditAbilities>(() => ({
|
||||||
|
canManage: can('commercial.clients.manage'),
|
||||||
|
canAccountingView: can('commercial.clients.accounting.view'),
|
||||||
|
canAccountingManage: can('commercial.clients.accounting.manage'),
|
||||||
|
}))
|
||||||
|
const editability = computed(() => resolveTabEditability(abilities.value))
|
||||||
|
// Bloc principal + onglets Information / Contact / Adresse.
|
||||||
|
const businessReadonly = computed(() => !editability.value.businessEditable)
|
||||||
|
const canAccountingView = computed(() => editability.value.accountingVisible)
|
||||||
|
const accountingReadonly = computed(() => !editability.value.accountingEditable)
|
||||||
|
|
||||||
|
const headerTitle = computed(() => client.value?.companyName ?? t('commercial.clients.edit.title'))
|
||||||
|
|
||||||
|
// ── Brouillons editables (pre-remplis depuis le detail) ─────────────────────
|
||||||
|
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
|
||||||
|
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
|
||||||
|
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
|
||||||
|
const contacts = ref<ContactFormDraft[]>([])
|
||||||
|
const addresses = ref<AddressFormDraft[]>([])
|
||||||
|
const ribs = ref<RibFormDraft[]>([])
|
||||||
|
|
||||||
|
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
||||||
|
const removedContactIds = ref<number[]>([])
|
||||||
|
const removedAddressIds = ref<number[]>([])
|
||||||
|
const removedRibIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const mainSubmitting = ref(false)
|
||||||
|
const tabSubmitting = ref(false)
|
||||||
|
const addressDegradedNotified = ref(false)
|
||||||
|
|
||||||
|
/** Recopie le detail charge dans les brouillons editables. */
|
||||||
|
function hydrate(detail: ClientDetail): void {
|
||||||
|
Object.assign(main, mapMainDraft(detail))
|
||||||
|
Object.assign(information, mapInformationDraft(detail))
|
||||||
|
Object.assign(accounting, mapAccountingFormDraft(detail))
|
||||||
|
contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
|
||||||
|
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
|
||||||
|
ribs.value = (detail.ribs ?? []).map(mapRibToDraft)
|
||||||
|
// Charge les listes distributeur / courtier si une relation est deja posee.
|
||||||
|
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
|
||||||
|
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Options de selects (referentiels UNION valeurs courantes de l'embed) ─────
|
||||||
|
// L'union garantit que les valeurs deja posees s'affichent meme quand le
|
||||||
|
// referentiel complet n'est pas chargeable (roles metier sans
|
||||||
|
// catalog.categories.view / sites.view → 403, cf. matrice § 2.7).
|
||||||
|
function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[] {
|
||||||
|
const seen = new Set(primary.map(o => o.value))
|
||||||
|
return [...primary, ...extra.filter(o => !seen.has(o.value))]
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedCategoryOptions = computed<CategoryOption[]>(() => {
|
||||||
|
const fromClient = categoryOptionsOf(client.value?.categories)
|
||||||
|
const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
|
||||||
|
return mergeOptions(fromClient, fromAddresses)
|
||||||
|
})
|
||||||
|
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
|
||||||
|
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
||||||
|
const addressCategoryOptions = computed(() =>
|
||||||
|
mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const embedSiteOptions = computed<RefOption[]>(() =>
|
||||||
|
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
|
||||||
|
)
|
||||||
|
const siteOptions = computed(() => mergeOptions(referentials.sites.value, embedSiteOptions.value))
|
||||||
|
|
||||||
|
// Contacts deja persistes (iri non null), rattachables a une adresse (M2M).
|
||||||
|
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 ?? ''),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const countryOptions: RefOption[] = [
|
||||||
|
{ value: 'France', label: 'France' },
|
||||||
|
{ value: 'Espagne', label: 'Espagne' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const relationOptions = computed<RefOption[]>(() => [
|
||||||
|
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
||||||
|
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Distributeur / courtier : referentiel charge a la demande UNION valeur courante.
|
||||||
|
const currentDistributorOption = computed<RefOption[]>(() => {
|
||||||
|
const d = client.value?.distributor
|
||||||
|
return d && typeof d === 'object' ? [{ value: d['@id'], label: d.companyName ?? d['@id'] }] : []
|
||||||
|
})
|
||||||
|
const currentBrokerOption = computed<RefOption[]>(() => {
|
||||||
|
const b = client.value?.broker
|
||||||
|
return b && typeof b === 'object' ? [{ value: b['@id'], label: b.companyName ?? b['@id'] }] : []
|
||||||
|
})
|
||||||
|
const distributorOptions = computed(() => mergeOptions(referentials.distributors.value, currentDistributorOption.value))
|
||||||
|
const brokerOptions = computed(() => mergeOptions(referentials.brokers.value, currentBrokerOption.value))
|
||||||
|
|
||||||
|
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
|
||||||
|
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(client.value?.tvaMode)))
|
||||||
|
const paymentDelayOptions = computed(() => mergeOptions(referentials.paymentDelays.value, referentialOptionOf(client.value?.paymentDelay)))
|
||||||
|
const paymentTypeOptions = computed(() => mergeOptions(
|
||||||
|
referentials.paymentTypes.value.map(p => ({ value: p.value, label: p.label })),
|
||||||
|
referentialOptionOf(client.value?.paymentType),
|
||||||
|
))
|
||||||
|
const bankOptions = computed(() => mergeOptions(referentials.banks.value, referentialOptionOf(client.value?.bank)))
|
||||||
|
|
||||||
|
// ── Onglets : navigation libre (4 actifs + 4 coquilles, comme la consultation) ─
|
||||||
|
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
||||||
|
|
||||||
|
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 => ({
|
||||||
|
key,
|
||||||
|
label: t(`commercial.clients.tab.${key}`),
|
||||||
|
icon: TAB_ICONS[key],
|
||||||
|
})))
|
||||||
|
|
||||||
|
const activeTab = ref('information')
|
||||||
|
|
||||||
|
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||||
|
function goBack(): void {
|
||||||
|
router.push(`/clients/${clientId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message d'erreur a afficher : violation 422 / detail renvoye par le serveur,
|
||||||
|
* sinon un libelle generique. Le 409 d'unicite de nom (bloc principal) est
|
||||||
|
* traduit explicitement par l'appelant.
|
||||||
|
*/
|
||||||
|
function apiErrorMessage(e: unknown): string {
|
||||||
|
const data = (e as { data?: unknown })?.data
|
||||||
|
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void {
|
||||||
|
const status = (e as { response?: { status?: number } })?.response?.status
|
||||||
|
toast.error({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: opts.duplicateCompany && status === 409
|
||||||
|
? t('commercial.clients.form.duplicateCompany')
|
||||||
|
: apiErrorMessage(e),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bloc principal ───────────────────────────────────────────────────────────
|
||||||
|
const isMainValid = computed(() => {
|
||||||
|
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
|
||||||
|
const relationValid
|
||||||
|
= main.relationType === null
|
||||||
|
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|
||||||
|
|| (main.relationType === 'courtier' && filled(main.brokerIri))
|
||||||
|
return filled(main.companyName)
|
||||||
|
&& filled(main.email)
|
||||||
|
&& filled(main.phonePrimary)
|
||||||
|
&& (filled(main.firstName) || filled(main.lastName))
|
||||||
|
&& main.categoryIris.length >= 1
|
||||||
|
&& relationValid
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onRelationChange(value: string | number | null): Promise<void> {
|
||||||
|
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
|
||||||
|
main.relationType = relation
|
||||||
|
// Une seule FK 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().catch(() => {})
|
||||||
|
if (relation === 'courtier') await referentials.loadBrokers().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
|
||||||
|
async function submitMain(): Promise<void> {
|
||||||
|
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
|
||||||
|
mainSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
// Reaffiche les valeurs normalisees renvoyees par le serveur.
|
||||||
|
Object.assign(main, mapMainDraft(updated))
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e, { duplicateCompany: true })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
mainSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Information ───────────────────────────────────────────────────────
|
||||||
|
/** PATCH /clients/{id} — groupe client:write:information UNIQUEMENT. */
|
||||||
|
async function submitInformation(): Promise<void> {
|
||||||
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false })
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Contact ───────────────────────────────────────────────────────────
|
||||||
|
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'), () => {
|
||||||
|
const removed = contacts.value[index]
|
||||||
|
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
||||||
|
contacts.value.splice(index, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
||||||
|
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
||||||
|
* collection contacts (endpoints client_contact dedies).
|
||||||
|
*/
|
||||||
|
async function submitContacts(): Promise<void> {
|
||||||
|
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const id of removedContactIds.value) {
|
||||||
|
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
|
||||||
|
}
|
||||||
|
removedContactIds.value = []
|
||||||
|
|
||||||
|
for (const contact of contacts.value) {
|
||||||
|
if (!isContactNamed(contact)) continue
|
||||||
|
const body = buildContactPayload(contact)
|
||||||
|
if (contact.id === null) {
|
||||||
|
const created = await api.post<{ '@id'?: string, id: number }>(
|
||||||
|
`/clients/${clientId}/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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet 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'), () => {
|
||||||
|
const removed = addresses.value[index]
|
||||||
|
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
||||||
|
addresses.value.splice(index, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
||||||
|
async function submitAddresses(): Promise<void> {
|
||||||
|
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const id of removedAddressIds.value) {
|
||||||
|
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
|
||||||
|
}
|
||||||
|
removedAddressIds.value = []
|
||||||
|
|
||||||
|
for (const address of addresses.value) {
|
||||||
|
const body = buildAddressPayload(address, isBillingEmailRequired(address))
|
||||||
|
if (address.id === null) {
|
||||||
|
const created = await api.post<{ id: number }>(
|
||||||
|
`/clients/${clientId}/addresses`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
address.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Comptabilite ──────────────────────────────────────────────────────
|
||||||
|
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)
|
||||||
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean {
|
||||||
|
const filled = (v: string | null) => v !== null && v.trim() !== ''
|
||||||
|
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
|
||||||
|
}
|
||||||
|
|
||||||
|
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'), () => {
|
||||||
|
const removed = ribs.value[index]
|
||||||
|
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
||||||
|
ribs.value.splice(index, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting,
|
||||||
|
* exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la
|
||||||
|
* sous-ressource. Aucun champ main/information dans le payload (mode strict
|
||||||
|
* RG-1.28 : sinon 403 sur tout le payload).
|
||||||
|
*/
|
||||||
|
async function submitAccounting(): Promise<void> {
|
||||||
|
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
|
||||||
|
|
||||||
|
for (const id of removedRibIds.value) {
|
||||||
|
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||||
|
}
|
||||||
|
removedRibIds.value = []
|
||||||
|
|
||||||
|
for (const rib of ribs.value) {
|
||||||
|
if (!ribIsComplete(rib)) continue
|
||||||
|
const body = buildRibPayload(rib)
|
||||||
|
if (rib.id === null) {
|
||||||
|
const created = await api.post<{ id: number }>(
|
||||||
|
`/clients/${clientId}/ribs`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
rib.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: headerTitle })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Referentiels en best-effort (echec non bloquant : l'embed alimente les
|
||||||
|
// libelles des valeurs courantes).
|
||||||
|
referentials.loadCommon().catch(() => {})
|
||||||
|
await load()
|
||||||
|
if (client.value) hydrate(client.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildAccountingPayload,
|
||||||
|
buildAddressPayload,
|
||||||
|
buildContactPayload,
|
||||||
|
buildInformationPayload,
|
||||||
|
buildMainPayload,
|
||||||
|
buildRibPayload,
|
||||||
|
mapAccountingFormDraft,
|
||||||
|
mapInformationDraft,
|
||||||
|
mapMainDraft,
|
||||||
|
resolveTabEditability,
|
||||||
|
type AccountingFormDraft,
|
||||||
|
type InformationFormDraft,
|
||||||
|
type MainFormDraft,
|
||||||
|
} from '../clientEdit'
|
||||||
|
import type { ClientDetail } from '../clientConsultation'
|
||||||
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
|
// ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ─────
|
||||||
|
|
||||||
|
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
|
||||||
|
return {
|
||||||
|
companyName: 'ACME',
|
||||||
|
firstName: 'Jean',
|
||||||
|
lastName: 'Dupont',
|
||||||
|
email: 'jean@acme.fr',
|
||||||
|
phonePrimary: '05 49 11 22 33',
|
||||||
|
phoneSecondary: null,
|
||||||
|
hasSecondaryPhone: false,
|
||||||
|
categoryIris: ['/api/categories/1'],
|
||||||
|
relationType: null,
|
||||||
|
distributorIri: null,
|
||||||
|
brokerIri: null,
|
||||||
|
triageService: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function informationDraft(overrides: Partial<InformationFormDraft> = {}): InformationFormDraft {
|
||||||
|
return {
|
||||||
|
description: 'desc',
|
||||||
|
competitors: 'concurrents',
|
||||||
|
foundedAt: '2010-05-01',
|
||||||
|
employeesCount: '42',
|
||||||
|
revenueAmount: '1000000',
|
||||||
|
profitAmount: '50000',
|
||||||
|
directorName: 'PDG',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): AccountingFormDraft {
|
||||||
|
return {
|
||||||
|
siren: '123456789',
|
||||||
|
accountNumber: 'C-001',
|
||||||
|
nTva: 'FR123',
|
||||||
|
tvaModeIri: '/api/tva_modes/1',
|
||||||
|
paymentDelayIri: '/api/payment_delays/1',
|
||||||
|
paymentTypeIri: '/api/payment_types/1',
|
||||||
|
bankIri: '/api/banks/1',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champs de chaque groupe de serialisation (miroir back ClientProcessor).
|
||||||
|
const MAIN_KEYS = [
|
||||||
|
'companyName', 'firstName', 'lastName', 'email', 'phonePrimary',
|
||||||
|
'phoneSecondary', 'categories', 'distributor', 'broker', 'triageService',
|
||||||
|
]
|
||||||
|
const INFORMATION_KEYS = [
|
||||||
|
'description', 'competitors', 'foundedAt', 'employeesCount',
|
||||||
|
'revenueAmount', 'profitAmount', 'directorName',
|
||||||
|
]
|
||||||
|
const ACCOUNTING_KEYS = ['siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', 'bank']
|
||||||
|
|
||||||
|
describe('buildMainPayload — scoping strict groupe client:write:main', () => {
|
||||||
|
it('n\'expose QUE les champs du groupe main (aucune fuite information/accounting)', () => {
|
||||||
|
expect(Object.keys(buildMainPayload(mainDraft())).sort()).toEqual([...MAIN_KEYS].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('relation distributeur : renseigne distributor, force broker a null (RG-1.03)', () => {
|
||||||
|
const payload = buildMainPayload(mainDraft({
|
||||||
|
relationType: 'distributeur',
|
||||||
|
distributorIri: '/api/clients/9',
|
||||||
|
brokerIri: '/api/clients/7',
|
||||||
|
}))
|
||||||
|
expect(payload.distributor).toBe('/api/clients/9')
|
||||||
|
expect(payload.broker).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('relation courtier : renseigne broker, force distributor a null (RG-1.03)', () => {
|
||||||
|
const payload = buildMainPayload(mainDraft({
|
||||||
|
relationType: 'courtier',
|
||||||
|
distributorIri: '/api/clients/9',
|
||||||
|
brokerIri: '/api/clients/7',
|
||||||
|
}))
|
||||||
|
expect(payload.broker).toBe('/api/clients/7')
|
||||||
|
expect(payload.distributor).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sans relation : distributor et broker a null', () => {
|
||||||
|
const payload = buildMainPayload(mainDraft({ relationType: null }))
|
||||||
|
expect(payload.distributor).toBeNull()
|
||||||
|
expect(payload.broker).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('telephone secondaire non revele : envoie null meme si une valeur traine', () => {
|
||||||
|
const payload = buildMainPayload(mainDraft({ hasSecondaryPhone: false, phoneSecondary: '06 00 00 00 00' }))
|
||||||
|
expect(payload.phoneSecondary).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
|
||||||
|
it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => {
|
||||||
|
expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('convertit employeesCount en nombre et vide -> null', () => {
|
||||||
|
expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42)
|
||||||
|
expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull()
|
||||||
|
expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('chaines vides normalisees en null', () => {
|
||||||
|
const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' }))
|
||||||
|
expect(payload.description).toBeNull()
|
||||||
|
expect(payload.directorName).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
||||||
|
it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => {
|
||||||
|
expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => {
|
||||||
|
expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1')
|
||||||
|
expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
|
||||||
|
it('contact : telephone secondaire ignore si non revele', () => {
|
||||||
|
const contact: ContactFormDraft = {
|
||||||
|
id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B',
|
||||||
|
jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000',
|
||||||
|
email: null, hasSecondaryPhone: false,
|
||||||
|
}
|
||||||
|
expect(buildContactPayload(contact).phoneSecondary).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
|
||||||
|
const address: AddressFormDraft = {
|
||||||
|
id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France',
|
||||||
|
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
|
||||||
|
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
|
||||||
|
billingEmail: 'facturation@acme.fr',
|
||||||
|
}
|
||||||
|
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
|
||||||
|
expect(buildAddressPayload(address, false).billingEmail).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rib : label / bic / iban transmis tels quels', () => {
|
||||||
|
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
|
||||||
|
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapMainDraft — pre-remplissage bloc principal', () => {
|
||||||
|
it('formate les telephones, resout la relation et extrait les IRI', () => {
|
||||||
|
const client = {
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr',
|
||||||
|
phonePrimary: '0549112233', phoneSecondary: '0600000000', triageService: true,
|
||||||
|
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
|
||||||
|
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
|
||||||
|
} as ClientDetail
|
||||||
|
|
||||||
|
const draft = mapMainDraft(client)
|
||||||
|
expect(draft.phonePrimary).toBe('05 49 11 22 33')
|
||||||
|
expect(draft.phoneSecondary).toBe('06 00 00 00 00')
|
||||||
|
expect(draft.hasSecondaryPhone).toBe(true)
|
||||||
|
expect(draft.categoryIris).toEqual(['/api/categories/1'])
|
||||||
|
expect(draft.relationType).toBe('distributeur')
|
||||||
|
expect(draft.distributorIri).toBe('/api/clients/9')
|
||||||
|
expect(draft.brokerIri).toBeNull()
|
||||||
|
expect(draft.triageService).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gere les cles omises (skip_null_values) sans planter', () => {
|
||||||
|
const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail)
|
||||||
|
expect(draft.companyName).toBeNull()
|
||||||
|
expect(draft.hasSecondaryPhone).toBe(false)
|
||||||
|
expect(draft.categoryIris).toEqual([])
|
||||||
|
expect(draft.relationType).toBeNull()
|
||||||
|
expect(draft.triageService).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapInformationDraft — pre-remplissage onglet Information', () => {
|
||||||
|
it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => {
|
||||||
|
const draft = mapInformationDraft({
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000',
|
||||||
|
} as ClientDetail)
|
||||||
|
expect(draft.foundedAt).toBe('2010-05-01')
|
||||||
|
expect(draft.employeesCount).toBe('42')
|
||||||
|
expect(draft.revenueAmount).toBe('1000000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cles omises -> null', () => {
|
||||||
|
const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail)
|
||||||
|
expect(draft.foundedAt).toBeNull()
|
||||||
|
expect(draft.employeesCount).toBeNull()
|
||||||
|
expect(draft.description).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
|
||||||
|
it('extrait les scalaires et les IRI des referentiels embarques', () => {
|
||||||
|
const draft = mapAccountingFormDraft({
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
siren: '123456789', accountNumber: 'C-001', nTva: 'FR123',
|
||||||
|
tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' },
|
||||||
|
paymentType: '/api/payment_types/3',
|
||||||
|
} as ClientDetail)
|
||||||
|
expect(draft.siren).toBe('123456789')
|
||||||
|
expect(draft.tvaModeIri).toBe('/api/tva_modes/2')
|
||||||
|
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
|
||||||
|
expect(draft.bankIri).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
|
||||||
|
it('Admin : tout editable', () => {
|
||||||
|
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
|
||||||
|
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
|
||||||
|
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
|
||||||
|
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
|
||||||
|
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
|
||||||
|
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Sans permission d\'edition : rien d\'editable', () => {
|
||||||
|
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
|
||||||
|
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12).
|
||||||
|
*
|
||||||
|
* Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) :
|
||||||
|
* 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed
|
||||||
|
* contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites
|
||||||
|
* par la page et les blocs reutilisables (mappers contacts/adresses/ribs/
|
||||||
|
* comptabilite reutilises depuis clientConsultation).
|
||||||
|
* 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / ERP-74) : chaque
|
||||||
|
* onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un
|
||||||
|
* payload mixte — un champ hors-permission = 403 sur l'integralite cote back.
|
||||||
|
*
|
||||||
|
* Ces helpers ne touchent ni a l'API ni a l'etat reactif.
|
||||||
|
*
|
||||||
|
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON
|
||||||
|
* miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de
|
||||||
|
* role et Bureau partage les permissions de Commerciale. Le back l'applique de
|
||||||
|
* maniere fiable (422) ; on laisse remonter ce 422 en toast.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
iriOf,
|
||||||
|
relationOf,
|
||||||
|
type ClientDetail,
|
||||||
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des
|
||||||
|
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
|
||||||
|
* contact principal, telephones, email, categories, relation, triage), pas sur
|
||||||
|
* une sous-ressource ClientContact.
|
||||||
|
*/
|
||||||
|
export interface MainFormDraft {
|
||||||
|
companyName: string | null
|
||||||
|
firstName: string | null
|
||||||
|
lastName: string | null
|
||||||
|
email: string | null
|
||||||
|
phonePrimary: string | null
|
||||||
|
phoneSecondary: string | null
|
||||||
|
/** UI : le 2e numero a ete revele (ou existait deja au chargement). */
|
||||||
|
hasSecondaryPhone: boolean
|
||||||
|
/** IRI des categories rattachees (M2M). */
|
||||||
|
categoryIris: string[]
|
||||||
|
relationType: 'distributeur' | 'courtier' | null
|
||||||
|
distributorIri: string | null
|
||||||
|
brokerIri: string | null
|
||||||
|
triageService: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etat « plat » de l'onglet Information (groupe client:write:information). */
|
||||||
|
export interface InformationFormDraft {
|
||||||
|
description: string | null
|
||||||
|
competitors: string | null
|
||||||
|
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||||
|
foundedAt: string | null
|
||||||
|
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||||
|
employeesCount: string | null
|
||||||
|
revenueAmount: string | null
|
||||||
|
profitAmount: string | null
|
||||||
|
directorName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etat « plat » de l'onglet Comptabilite (groupe client:write:accounting). */
|
||||||
|
export interface AccountingFormDraft {
|
||||||
|
siren: string | null
|
||||||
|
accountNumber: string | null
|
||||||
|
nTva: string | null
|
||||||
|
tvaModeIri: string | null
|
||||||
|
paymentDelayIri: string | null
|
||||||
|
paymentTypeIri: string | null
|
||||||
|
bankIri: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un client. */
|
||||||
|
export interface ClientEditAbilities {
|
||||||
|
/** `commercial.clients.manage` : bloc principal + onglets metier. */
|
||||||
|
canManage: boolean
|
||||||
|
/** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */
|
||||||
|
canAccountingView: boolean
|
||||||
|
/** `commercial.clients.accounting.manage` : edition de l'onglet Comptabilite. */
|
||||||
|
canAccountingManage: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
|
||||||
|
export interface TabEditability {
|
||||||
|
/** Bloc principal + onglets Information / Contact / Adresse editables. */
|
||||||
|
businessEditable: boolean
|
||||||
|
/** Onglet Comptabilite present (affiche). */
|
||||||
|
accountingVisible: boolean
|
||||||
|
/** Onglet Comptabilite editable. */
|
||||||
|
accountingEditable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappe le detail client vers le brouillon du bloc principal. Les telephones
|
||||||
|
* sont reformates XX XX XX XX XX (RG d'affichage). La relation Distributeur/
|
||||||
|
* Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait de l'embed.
|
||||||
|
*/
|
||||||
|
export function mapMainDraft(client: ClientDetail): MainFormDraft {
|
||||||
|
const relation = relationOf(client)
|
||||||
|
const phoneSecondary = client.phoneSecondary ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyName: client.companyName ?? null,
|
||||||
|
firstName: client.firstName ?? null,
|
||||||
|
lastName: client.lastName ?? null,
|
||||||
|
email: client.email ?? null,
|
||||||
|
phonePrimary: client.phonePrimary ? formatPhoneFR(client.phonePrimary) : null,
|
||||||
|
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
||||||
|
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
||||||
|
categoryIris: (client.categories ?? []).map(c => c['@id']),
|
||||||
|
relationType: relation.type,
|
||||||
|
distributorIri: iriOf(client.distributor),
|
||||||
|
brokerIri: iriOf(client.broker),
|
||||||
|
triageService: client.triageService === true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe le detail client vers le brouillon de l'onglet Information. */
|
||||||
|
export function mapInformationDraft(client: ClientDetail): InformationFormDraft {
|
||||||
|
return {
|
||||||
|
description: client.description ?? null,
|
||||||
|
competitors: client.competitors ?? null,
|
||||||
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||||
|
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
||||||
|
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
||||||
|
revenueAmount: client.revenueAmount ?? null,
|
||||||
|
profitAmount: client.profitAmount ?? null,
|
||||||
|
directorName: client.directorName ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
|
||||||
|
export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft {
|
||||||
|
return {
|
||||||
|
siren: client.siren ?? null,
|
||||||
|
accountNumber: client.accountNumber ?? null,
|
||||||
|
nTva: client.nTva ?? null,
|
||||||
|
tvaModeIri: iriOf(client.tvaMode),
|
||||||
|
paymentDelayIri: iriOf(client.paymentDelay),
|
||||||
|
paymentTypeIri: iriOf(client.paymentType),
|
||||||
|
bankIri: iriOf(client.bank),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scoping strict des payloads PATCH ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation
|
||||||
|
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
|
||||||
|
* que la FK correspondant au type choisi, l'autre est forcee a null.
|
||||||
|
*/
|
||||||
|
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
companyName: main.companyName,
|
||||||
|
firstName: main.firstName || null,
|
||||||
|
lastName: main.lastName || null,
|
||||||
|
email: main.email,
|
||||||
|
phonePrimary: main.phonePrimary || null,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
|
||||||
|
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload des scalaires de l'onglet Comptabilite — groupe client:write:accounting
|
||||||
|
* UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque
|
||||||
|
* n'a de sens que pour un Virement (RG-1.12) : forcee a null sinon.
|
||||||
|
*/
|
||||||
|
export function buildAccountingPayload(
|
||||||
|
accounting: AccountingFormDraft,
|
||||||
|
isBankRequired: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
siren: accounting.siren || null,
|
||||||
|
accountNumber: accounting.accountNumber || null,
|
||||||
|
tvaMode: accounting.tvaModeIri,
|
||||||
|
nTva: accounting.nTva || null,
|
||||||
|
paymentDelay: accounting.paymentDelayIri,
|
||||||
|
paymentType: accounting.paymentTypeIri,
|
||||||
|
bank: isBankRequired ? accounting.bankIri : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload d'un contact (sous-ressource client_contact). */
|
||||||
|
export function buildContactPayload(contact: ContactFormDraft): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
firstName: contact.firstName || null,
|
||||||
|
lastName: contact.lastName || null,
|
||||||
|
jobTitle: contact.jobTitle || null,
|
||||||
|
phonePrimary: contact.phonePrimary || null,
|
||||||
|
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||||
|
email: contact.email || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload d'une adresse (sous-ressource client_address). */
|
||||||
|
export function buildAddressPayload(
|
||||||
|
address: AddressFormDraft,
|
||||||
|
isBillingEmailRequired: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
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.billingEmail || null) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload d'un RIB (sous-ressource client_rib). */
|
||||||
|
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
label: rib.label,
|
||||||
|
bic: rib.bic,
|
||||||
|
iban: rib.iban,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gating par permission ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
|
||||||
|
* miroir UI du re-gating champ-par-champ du ClientProcessor) :
|
||||||
|
* - bloc principal + Information/Contact/Adresse : editables ssi `manage` ;
|
||||||
|
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
|
||||||
|
*
|
||||||
|
* Produit le comportement attendu :
|
||||||
|
* - Admin : tout editable.
|
||||||
|
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
|
||||||
|
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
|
||||||
|
*/
|
||||||
|
export function resolveTabEditability(abilities: ClientEditAbilities): TabEditability {
|
||||||
|
return {
|
||||||
|
businessEditable: abilities.canManage,
|
||||||
|
accountingVisible: abilities.canAccountingView,
|
||||||
|
accountingEditable: abilities.canAccountingManage,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user